1use 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#[derive(Debug, Clone)]
33pub struct PaginationInfo {
34 pub skip: usize,
35 pub limit: usize,
36}
37
38#[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 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 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 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 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 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}