Skip to main content

linkrs_query/planning/statements/
match_statement_planner.rs

1//! Unified MATCH Statement Planner
2//!
3//! Implement the StatementPlanner interface to handle the complete planning of MATCH queries.
4//! It integrates the following functions:
5//!   - Node and edge pattern matching (supports multiple paths)
6//!   - WHERE condition filtering
7//!   - RETURN Projection
8//!   - ORDER BY: Sorting
9//!   - LIMIT/SKIP – Pagination options
10//!   - Selection of intelligent scanning strategies (index scanning, attribute scanning, full table scanning)
11
12use crate::binder::validation::CypherClauseKind;
13use crate::binder::validation::ValidationInfo;
14use crate::binder::BoundStatement;
15use crate::metadata::MetadataContext;
16use crate::parser::ast::Stmt;
17use crate::planning::join_order::JoinOrderStats;
18use crate::planning::plan::SubPlan;
19use crate::planning::planner::{Planner, PlannerError, ValidatedStatement};
20use crate::planning::statements::clauses::{
21    OrderByClausePlanner, PaginationPlanner, ReturnClausePlanner, WhereClausePlanner,
22};
23use crate::planning::statements::pattern_planner;
24use crate::planning::statements::pattern_planner::PlanningContext;
25use crate::planning::statements::plan_combiner;
26use crate::planning::statements::statement_planner::{ClausePlanner, StatementPlanner};
27use crate::QueryContext;
28use linkrs_core::types::expr::expression_context::ExpressionAnalysisContext;
29use std::sync::Arc;
30
31/// Pagination Information Structure
32#[derive(Debug, Clone)]
33pub struct PaginationInfo {
34    pub skip: usize,
35    pub limit: usize,
36}
37
38/// MATCH Statement Planner
39///
40/// Responsible for converting MATCH queries into executable execution plans.
41/// Implement the StatementPlanner interface to provide a unified planning entry point.
42/// Delegates clause-level planning (WHERE, RETURN, ORDER BY, LIMIT) to ClausePlanner implementations.
43/// Delegates pattern planning, index selection, plan combination, and expression construction
44/// to dedicated sub-modules.
45#[derive(Debug, Clone)]
46pub struct MatchStatementPlanner {
47    config: MatchPlannerConfig,
48    expr_context: Option<Arc<ExpressionAnalysisContext>>,
49    metadata_context: Option<MetadataContext>,
50    join_order_stats: Option<JoinOrderStats>,
51    where_planner: WhereClausePlanner,
52    return_planner: ReturnClausePlanner,
53    order_by_planner: OrderByClausePlanner,
54    pagination_planner: PaginationPlanner,
55}
56
57#[derive(Debug, Clone)]
58pub struct MatchPlannerConfig {
59    pub default_limit: usize,
60    pub max_limit: usize,
61    pub enable_index_optimization: bool,
62}
63
64impl Default for MatchPlannerConfig {
65    fn default() -> Self {
66        Self {
67            default_limit: 10000,
68            max_limit: 100000,
69            enable_index_optimization: true,
70        }
71    }
72}
73
74impl Default for MatchStatementPlanner {
75    fn default() -> Self {
76        Self::new()
77    }
78}
79
80impl MatchStatementPlanner {
81    pub fn new() -> Self {
82        Self {
83            config: MatchPlannerConfig::default(),
84            expr_context: None,
85            metadata_context: None,
86            join_order_stats: None,
87            where_planner: WhereClausePlanner::new(),
88            return_planner: ReturnClausePlanner::new(),
89            order_by_planner: OrderByClausePlanner::new(),
90            pagination_planner: PaginationPlanner::new(),
91        }
92    }
93
94    pub fn with_config(config: MatchPlannerConfig) -> Self {
95        Self {
96            config,
97            expr_context: None,
98            metadata_context: None,
99            join_order_stats: None,
100            where_planner: WhereClausePlanner::new(),
101            return_planner: ReturnClausePlanner::new(),
102            order_by_planner: OrderByClausePlanner::new(),
103            pagination_planner: PaginationPlanner::new(),
104        }
105    }
106
107    /// Attach real cardinality statistics for the join-order path.
108    /// Without statistics the enumerator falls back to default constants.
109    pub fn with_join_order_stats(mut self, stats: JoinOrderStats) -> Self {
110        self.join_order_stats = Some(stats);
111        self
112    }
113}
114
115impl Planner for MatchStatementPlanner {
116    fn match_planner(&self, stmt: &Stmt) -> bool {
117        matches!(stmt, Stmt::Match(_))
118    }
119
120    fn transform(
121        &mut self,
122        validated: &ValidatedStatement,
123        qctx: Arc<QueryContext>,
124    ) -> Result<SubPlan, PlannerError> {
125        let space_id = qctx.space_id().unwrap_or(1);
126        let space_name = qctx.space_name().unwrap_or_else(|| "default".to_string());
127
128        let validation_info = &validated.validation_info;
129
130        self.expr_context = Some(validated.ast.expr_context().clone());
131
132        for hint in &validation_info.optimization_hints {
133            log::debug!("Optimization Tip: {:?}", hint);
134        }
135
136        self.plan_match_pattern(
137            validated,
138            space_id,
139            &space_name,
140            validation_info,
141            &qctx,
142            None,
143        )
144    }
145
146    fn plan_bound(
147        &mut self,
148        ctx: &crate::planning::context::PlanContext<'_>,
149    ) -> Result<SubPlan, PlannerError> {
150        let bound = ctx.bound;
151        let qctx = ctx.qctx.clone();
152        let metadata = ctx.metadata;
153        let validated = ctx.validated;
154        let _ = (&bound, &qctx, &metadata, &validated);
155        let bound_delete = match bound {
156            BoundStatement::Match(m) => m.delete_clause.clone(),
157            _ => {
158                return Err(PlannerError::UnsupportedOperation(
159                    "Expected a MATCH bound statement".to_string(),
160                ));
161            }
162        };
163
164        let space_id = qctx.space_id().unwrap_or(1);
165        let space_name = qctx.space_name().unwrap_or_else(|| "default".to_string());
166
167        if let Some(metadata_context) = metadata {
168            self.metadata_context = Some(metadata_context.clone());
169        }
170
171        self.expr_context = Some(validated.ast.expr_context().clone());
172
173        let validation_info = &validated.validation_info;
174
175        for hint in &validation_info.optimization_hints {
176            log::debug!("Optimization Tip: {:?}", hint);
177        }
178
179        self.plan_match_pattern(
180            validated,
181            space_id,
182            &space_name,
183            validation_info,
184            &qctx,
185            bound_delete,
186        )
187    }
188}
189
190impl StatementPlanner for MatchStatementPlanner {
191    fn statement_type(&self) -> &'static str {
192        "MATCH"
193    }
194
195    fn supported_clause_kinds(&self) -> &[CypherClauseKind] {
196        const SUPPORTED_CLAUSES: &[CypherClauseKind] = &[
197            CypherClauseKind::Match,
198            CypherClauseKind::Where,
199            CypherClauseKind::Return,
200            CypherClauseKind::OrderBy,
201            CypherClauseKind::Pagination,
202        ];
203        SUPPORTED_CLAUSES
204    }
205}
206
207impl MatchStatementPlanner {
208    fn plan_match_pattern(
209        &mut self,
210        validated: &ValidatedStatement,
211        space_id: u64,
212        space_name: &str,
213        validation_info: &ValidationInfo,
214        qctx: &Arc<QueryContext>,
215        bound_delete: Option<crate::binder::bound::BoundMatchDeleteClause>,
216    ) -> Result<SubPlan, PlannerError> {
217        let stmt = validated.stmt();
218        match stmt {
219            Stmt::Match(match_stmt) => {
220                let referenced_tags = &validation_info.semantic_info.referenced_tags;
221                if !referenced_tags.is_empty() {
222                    log::debug!("Quoted tags: {:?}", referenced_tags);
223                }
224
225                let planning_ctx = PlanningContext {
226                    space_id,
227                    space_name,
228                    validation_info,
229                    qctx,
230                    enable_index_optimization: self.config.enable_index_optimization,
231                    metadata_context: &self.metadata_context,
232                    expr_context: &self.expr_context,
233                    where_expression: match_stmt.where_clause.as_ref(),
234                    optional: match_stmt.optional,
235                };
236
237                // The join-order path covers every pattern at once; the
238                // legacy path plans the first pattern here and cross joins
239                // the rest below.
240                let (mut plan, joined_by_optimizer) = if match_stmt.patterns.is_empty() {
241                    (
242                        pattern_planner::plan_node_pattern(space_id, space_name)?,
243                        false,
244                    )
245                } else if let Some(join_plan) =
246                    self.try_join_order_plan(match_stmt, space_id, space_name)
247                {
248                    (join_plan, true)
249                } else {
250                    let first_pattern = &match_stmt.patterns[0];
251                    (
252                        pattern_planner::plan_path_pattern(first_pattern, &planning_ctx)?,
253                        false,
254                    )
255                };
256
257                if !joined_by_optimizer {
258                    for pattern in match_stmt.patterns.iter().skip(1) {
259                        let path_plan = pattern_planner::plan_path_pattern(pattern, &planning_ctx)?;
260                        plan = plan_combiner::cross_join_plans(plan, path_plan)?;
261                    }
262                }
263
264                if has_where_clause(stmt) {
265                    plan = self
266                        .where_planner
267                        .transform_clause(qctx.clone(), stmt, plan)?;
268                }
269
270                if has_return_clause(stmt) {
271                    let distinct = extract_distinct_flag_from_stmt(stmt);
272                    self.return_planner.set_distinct(distinct);
273                    plan = self
274                        .return_planner
275                        .transform_clause(qctx.clone(), stmt, plan)?;
276                }
277
278                if has_order_by_clause(stmt) {
279                    plan = self
280                        .order_by_planner
281                        .transform_clause(qctx.clone(), stmt, plan)?;
282                }
283
284                if has_pagination(stmt) {
285                    plan = self
286                        .pagination_planner
287                        .transform_clause(qctx.clone(), stmt, plan)?;
288                }
289
290                if let Some(delete_clause) = &match_stmt.delete_clause {
291                    plan = if let Some(bound_delete_clause) = bound_delete.as_ref() {
292                        let edge_type =
293                            pattern_planner::extract_edge_type_from_patterns(&match_stmt.patterns);
294                        pattern_planner::plan_match_delete_bound(
295                            plan,
296                            bound_delete_clause,
297                            space_name,
298                            edge_type,
299                        )?
300                    } else {
301                        pattern_planner::plan_match_delete(
302                            plan,
303                            delete_clause,
304                            space_name,
305                            match_stmt,
306                        )?
307                    };
308                }
309
310                Ok(plan)
311            }
312            _ => Err(PlannerError::InvalidOperation(
313                "Expected MATCH statement".to_string(),
314            )),
315        }
316    }
317
318    /// Try the join-order path for conjunctive MATCH patterns.
319    ///
320    /// Returns `None` (caller falls back to the legacy `ExpandAll` chain)
321    /// when the patterns are ineligible: optional matches, deletes,
322    /// patterns the graph converter rejected, graphs without rels
323    /// (single-node lookups plan better as direct scans), or single
324    /// pattern queries (extend joins do not correlate node-rel pairs).
325    fn try_join_order_plan(
326        &self,
327        match_stmt: &crate::parser::ast::MatchStmt,
328        space_id: u64,
329        space_name: &str,
330    ) -> Option<SubPlan> {
331        if match_stmt.optional || match_stmt.delete_clause.is_some() {
332            return None;
333        }
334        // Single-pattern queries use the legacy ExpandAll chain because
335        // the join-order enumerator's extend joins cannot correlate
336        // node-rel pairs (rel-only base scans don't include endpoint
337        // nodes, leading to cross products).
338        if match_stmt.patterns.len() <= 1 {
339            return None;
340        }
341        let graph =
342            crate::planning::join_order::query_graph_from_match_patterns(&match_stmt.patterns)?;
343        if graph.num_rels() == 0 {
344            return None;
345        }
346        let graph = std::sync::Arc::new(graph);
347        let mut enumerator = crate::planning::join_order::JoinOrderEnumerator::new()
348            .with_space(space_id, space_name);
349        if let Some(stats) = &self.join_order_stats {
350            enumerator = enumerator.with_stats(stats.clone());
351        }
352        // A user join hint (parsed `USING JOIN ...`) takes precedence over
353        // automatic enumeration; an unresolvable hint falls back to the
354        // legacy path rather than failing the query.
355        let logical = match &match_stmt.join_hint {
356            Some(hint) => {
357                let join_hint = crate::planning::join_order::JoinHint::from_ast(hint)?;
358                enumerator.plan_with_hint(&graph, &join_hint).ok()?
359            }
360            None => enumerator.plan_query_graph(&graph)?,
361        };
362        Some(SubPlan::from_logical_root(logical))
363    }
364}
365
366fn has_where_clause(stmt: &Stmt) -> bool {
367    matches!(stmt, Stmt::Match(match_stmt) if match_stmt.where_clause.is_some())
368}
369
370fn has_return_clause(stmt: &Stmt) -> bool {
371    matches!(stmt, Stmt::Match(match_stmt) if match_stmt.return_clause.is_some())
372}
373
374fn has_order_by_clause(stmt: &Stmt) -> bool {
375    matches!(stmt, Stmt::Match(match_stmt) if match_stmt.order_by.is_some())
376}
377
378fn has_pagination(stmt: &Stmt) -> bool {
379    matches!(stmt, Stmt::Match(match_stmt) if match_stmt.limit.is_some() || match_stmt.skip.is_some())
380}
381
382fn extract_distinct_flag_from_stmt(stmt: &Stmt) -> bool {
383    if let Stmt::Match(match_stmt) = stmt {
384        if let Some(return_clause) = &match_stmt.return_clause {
385            return return_clause.distinct;
386        }
387    }
388    false
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use crate::parser::ast::hint::JoinHintAst;
395    use crate::parser::ast::pattern::{
396        EdgePattern, NodePattern, PathElement, PathPattern, Pattern,
397    };
398    use crate::parser::ast::MatchStmt;
399    use linkrs_core::types::graph_schema::EdgeDirection;
400    use linkrs_core::types::Span;
401
402    fn node(var: &str) -> PathElement {
403        PathElement::Node(NodePattern::new(
404            Some(var.to_string()),
405            Vec::new(),
406            None,
407            Vec::new(),
408            Span::default(),
409        ))
410    }
411
412    fn edge(var: &str) -> PathElement {
413        PathElement::Edge(EdgePattern::new(
414            Some(var.to_string()),
415            Vec::new(),
416            None,
417            Vec::new(),
418            EdgeDirection::Out,
419            None,
420            Span::default(),
421        ))
422    }
423
424    fn path(elements: Vec<PathElement>) -> Pattern {
425        Pattern::Path(PathPattern::new(elements, Span::default()))
426    }
427
428    fn stmt(patterns: Vec<Pattern>, join_hint: Option<JoinHintAst>, optional: bool) -> MatchStmt {
429        MatchStmt {
430            span: Span::default(),
431            patterns,
432            join_hint,
433            where_clause: None,
434            return_clause: None,
435            order_by: None,
436            limit: None,
437            skip: None,
438            optional,
439            delete_clause: None,
440        }
441    }
442
443    fn two_hop() -> Vec<Pattern> {
444        vec![
445            path(vec![node("a"), edge("e1"), node("b")]),
446            path(vec![node("a"), edge("e2"), node("c")]),
447        ]
448    }
449
450    #[test]
451    fn conjunctive_match_uses_join_order() {
452        let planner = MatchStatementPlanner::new();
453        let plan = planner.try_join_order_plan(&stmt(two_hop(), None, false), 1, "default");
454        assert!(plan.is_some(), "two-hop MATCH should take the join path");
455    }
456
457    #[test]
458    fn binary_hint_plans_through_solver() {
459        let planner = MatchStatementPlanner::new();
460        let hint = JoinHintAst::Binary {
461            left: "e1".to_string(),
462            right: "e2".to_string(),
463        };
464        let plan = planner.try_join_order_plan(&stmt(two_hop(), Some(hint), false), 1, "default");
465        assert!(plan.is_some(), "hinted MATCH should plan");
466    }
467
468    #[test]
469    fn optional_match_falls_back() {
470        let planner = MatchStatementPlanner::new();
471        let plan = planner.try_join_order_plan(&stmt(two_hop(), None, true), 1, "default");
472        assert!(plan.is_none(), "OPTIONAL MATCH keeps the legacy path");
473    }
474
475    #[test]
476    fn unknown_hint_variable_falls_back() {
477        let planner = MatchStatementPlanner::new();
478        let hint = JoinHintAst::Binary {
479            left: "e1".to_string(),
480            right: "zzz".to_string(),
481        };
482        let plan = planner.try_join_order_plan(&stmt(two_hop(), Some(hint), false), 1, "default");
483        assert!(plan.is_none(), "bad hint falls back instead of failing");
484    }
485
486    #[test]
487    fn single_pattern_falls_back() {
488        let planner = MatchStatementPlanner::new();
489        let patterns = vec![path(vec![node("a"), edge("e1"), node("b")])];
490        let plan = planner.try_join_order_plan(&stmt(patterns, None, false), 1, "default");
491        assert!(
492            plan.is_none(),
493            "single-pattern MATCH uses the legacy ExpandAll path"
494        );
495    }
496}