1use std::collections::HashSet;
7
8use super::filter_strategy::{estimate_filter_stats, resolve_filter_strategy};
9use super::formatter;
10use super::node_stats;
11use super::types::{
12 AggregatePlan, FilterPlan, FilterStrategy, FusionInfo, GroupByPlan, IndexLookupPlan, IndexType,
13 JoinPlanNode, LimitPlan, MatchTraversalPlan, OffsetPlan, PlanNode, QueryPlan, SortPlan,
14 TableScanPlan, VectorSearchPlan,
15};
16use crate::collection::stats::CollectionStats as CoreCollectionStats;
17use crate::velesql::ast::{Condition, LetBinding, SelectStatement, DEFAULT_SELECT_LIMIT};
18use crate::velesql::match_planner::{CollectionStats, MatchExecutionStrategy, MatchQueryPlanner};
19use crate::velesql::MatchClause;
20
21impl QueryPlan {
22 #[must_use]
24 pub fn from_select(stmt: &SelectStatement) -> Self {
25 Self::from_select_with_stats(stmt, &HashSet::new(), None)
26 }
27
28 #[must_use]
30 pub fn from_select_with_indexed_fields(
31 stmt: &SelectStatement,
32 indexed_fields: &HashSet<String>,
33 ) -> Self {
34 Self::from_select_with_stats(stmt, indexed_fields, None)
35 }
36
37 #[must_use]
44 pub fn from_select_with_stats(
45 stmt: &SelectStatement,
46 indexed_fields: &HashSet<String>,
47 stats: Option<&CoreCollectionStats>,
48 ) -> Self {
49 Self::build_select_plan(stmt, indexed_fields, stats, true)
50 }
51
52 fn build_select_plan(
59 stmt: &SelectStatement,
60 indexed_fields: &HashSet<String>,
61 stats: Option<&CoreCollectionStats>,
62 implicit_limit: bool,
63 ) -> Self {
64 let mut has_vector_search = false;
65 let mut filter_conditions = Vec::new();
66 let mut index_lookup = None;
67
68 if let Some(ref condition) = stmt.where_clause {
69 Self::analyze_condition(condition, &mut has_vector_search, &mut filter_conditions);
70 index_lookup = Self::extract_index_lookup(condition, indexed_fields);
71 }
72
73 let (mut nodes, index_used) = Self::build_scan_node(stmt, has_vector_search, index_lookup);
74 let filter_strategy = Self::append_filter_nodes_with_stats(
75 &mut nodes,
76 &filter_conditions,
77 stmt,
78 has_vector_search,
79 stats,
80 );
81 Self::append_post_filter_nodes(&mut nodes, stmt);
82 Self::push_pagination_nodes(&mut nodes, stmt, implicit_limit);
83
84 let mut plan = Self::assemble_plan_with_stats(
85 nodes,
86 index_used,
87 filter_strategy,
88 has_vector_search,
89 stats,
90 );
91 plan.with_options = Self::extract_with_options(stmt);
92 plan.fusion_info = Self::extract_fusion_info(stmt);
93 plan
94 }
95
96 #[must_use]
98 pub fn from_query(query: &crate::velesql::ast::Query) -> Self {
99 Self::from_query_with_stats(query, &HashSet::new(), None)
100 }
101
102 #[must_use]
105 pub fn from_query_with_stats(
106 query: &crate::velesql::ast::Query,
107 indexed_fields: &HashSet<String>,
108 stats: Option<&CoreCollectionStats>,
109 ) -> Self {
110 Self::from_query_with_all_stats(query, indexed_fields, stats, None)
111 }
112
113 #[must_use]
123 pub fn from_query_with_all_stats(
124 query: &crate::velesql::ast::Query,
125 indexed_fields: &HashSet<String>,
126 stats: Option<&CoreCollectionStats>,
127 match_stats: Option<&CollectionStats>,
128 ) -> Self {
129 let mut plan = if let Some(ref match_clause) = query.match_clause {
130 let default_stats = CollectionStats::default();
131 Self::from_match(match_clause, match_stats.unwrap_or(&default_stats))
132 } else {
133 let implicit_limit = query.compound.is_none();
135 Self::build_select_plan(&query.select, indexed_fields, stats, implicit_limit)
136 };
137 plan.let_bindings = Self::format_let_bindings(&query.let_bindings);
138 plan
139 }
140
141 #[must_use]
143 pub fn from_match(match_clause: &MatchClause, stats: &CollectionStats) -> Self {
144 let strategy = MatchQueryPlanner::plan(match_clause, stats);
145 let strategy_explanation = MatchQueryPlanner::explain(&strategy);
146
147 let (start_labels, max_depth, has_similarity, similarity_threshold) =
148 Self::extract_strategy_info(&strategy);
149
150 let relationship_count = match_clause
151 .patterns
152 .first()
153 .map_or(0, |p| p.relationships.len());
154
155 let traversal = PlanNode::MatchTraversal(MatchTraversalPlan {
156 strategy: strategy_explanation,
157 start_labels,
158 max_depth,
159 relationship_count,
160 has_similarity,
161 similarity_threshold,
162 });
163
164 let mut nodes = vec![traversal];
165 if let Some(limit) = match_clause.return_clause.limit {
166 nodes.push(PlanNode::Limit(LimitPlan {
167 count: limit,
168 is_default: false,
169 }));
170 }
171
172 let index_used = if has_similarity {
173 Some(IndexType::Hnsw)
174 } else {
175 None
176 };
177
178 Self::assemble_plan_with_stats(
179 nodes,
180 index_used,
181 FilterStrategy::None,
182 has_similarity,
183 None,
184 )
185 }
186
187 fn assemble_plan_with_stats(
189 mut nodes: Vec<PlanNode>,
190 index_used: Option<IndexType>,
191 filter_strategy: FilterStrategy,
192 has_vector_search: bool,
193 stats: Option<&CoreCollectionStats>,
194 ) -> Self {
195 let root = if nodes.len() == 1 {
196 nodes.swap_remove(0)
197 } else {
198 PlanNode::Sequence(nodes)
199 };
200 let estimated_cost_ms = node_stats::estimate_cost(&root, has_vector_search, stats);
201 Self {
202 root,
203 estimated_cost_ms,
204 index_used,
205 filter_strategy,
206 with_options: Vec::new(),
207 let_bindings: Vec::new(),
208 fusion_info: None,
209 cache_hit: None,
210 plan_reuse_count: None,
211 }
212 }
213
214 const DEFAULT_EF_SEARCH: u32 = 100;
216
217 fn build_scan_node(
219 stmt: &SelectStatement,
220 has_vector_search: bool,
221 index_lookup: Option<(String, String)>,
222 ) -> (Vec<PlanNode>, Option<IndexType>) {
223 let mut nodes = Vec::new();
224 let index_used;
225
226 if has_vector_search {
227 index_used = Some(IndexType::Hnsw);
228 let candidates =
229 u32::try_from(stmt.limit.unwrap_or(DEFAULT_SELECT_LIMIT)).unwrap_or(u32::MAX);
230 let ef_search = Self::resolve_ef_search(stmt);
231 nodes.push(PlanNode::VectorSearch(VectorSearchPlan {
232 collection: stmt.from.clone(),
233 ef_search,
234 candidates,
235 }));
236 } else if let Some((property, value)) = index_lookup {
237 index_used = Some(IndexType::Property);
238 nodes.push(PlanNode::IndexLookup(IndexLookupPlan {
239 label: stmt.from.clone(),
240 property,
241 value,
242 }));
243 } else {
244 index_used = None;
245 nodes.push(PlanNode::TableScan(TableScanPlan {
246 collection: stmt.from.clone(),
247 }));
248 }
249
250 (nodes, index_used)
251 }
252
253 #[allow(clippy::cast_possible_truncation)]
255 fn resolve_ef_search(stmt: &SelectStatement) -> u32 {
256 stmt.with_clause
257 .as_ref()
258 .and_then(crate::velesql::ast::WithClause::get_ef_search)
259 .map_or(Self::DEFAULT_EF_SEARCH, |v| v as u32)
260 }
261
262 fn extract_with_options(stmt: &SelectStatement) -> Vec<(String, String)> {
264 let Some(ref wc) = stmt.with_clause else {
265 return Vec::new();
266 };
267 wc.options
268 .iter()
269 .map(|opt| (opt.key.clone(), formatter::format_with_value(&opt.value)))
270 .collect()
271 }
272
273 fn extract_fusion_info(stmt: &SelectStatement) -> Option<FusionInfo> {
275 let fc = stmt.fusion_clause.as_ref()?;
276 let strategy = match fc.strategy {
277 crate::velesql::ast::FusionStrategyType::Rrf => "RRF",
278 crate::velesql::ast::FusionStrategyType::Weighted => "Weighted",
279 crate::velesql::ast::FusionStrategyType::Maximum => "Maximum",
280 crate::velesql::ast::FusionStrategyType::Rsf => "RSF",
281 crate::velesql::ast::FusionStrategyType::Average => "Average",
282 };
283 let weights = Self::format_fusion_weights(fc);
284 Some(FusionInfo {
285 strategy: strategy.to_string(),
286 k: fc.k,
287 weights,
288 })
289 }
290
291 fn format_fusion_weights(fc: &crate::velesql::ast::FusionClause) -> Option<String> {
293 let mut parts = Vec::new();
294 if let Some(vw) = fc.vector_weight {
295 parts.push(format!("vector={vw}"));
296 }
297 if let Some(gw) = fc.graph_weight {
298 parts.push(format!("graph={gw}"));
299 }
300 if let Some(dw) = fc.dense_weight {
301 parts.push(format!("dense={dw}"));
302 }
303 if let Some(sw) = fc.sparse_weight {
304 parts.push(format!("sparse={sw}"));
305 }
306 if parts.is_empty() {
307 None
308 } else {
309 Some(parts.join(", "))
310 }
311 }
312
313 fn format_let_bindings(bindings: &[LetBinding]) -> Vec<String> {
315 bindings
316 .iter()
317 .map(|b| format!("{} = {}", b.name, b.expr))
318 .collect()
319 }
320
321 fn append_filter_nodes_with_stats(
328 nodes: &mut Vec<PlanNode>,
329 filter_conditions: &[String],
330 stmt: &SelectStatement,
331 has_vector_search: bool,
332 stats: Option<&CoreCollectionStats>,
333 ) -> FilterStrategy {
334 let mut filter_strategy = FilterStrategy::None;
335
336 if !filter_conditions.is_empty() {
337 let heuristic_fallback = Self::estimate_selectivity(filter_conditions);
338 let (selectivity, estimation_method, estimated_rows) =
339 estimate_filter_stats(stmt, heuristic_fallback, stats);
340
341 let ef_search = Self::resolve_ef_search(stmt);
347 let candidates =
348 u32::try_from(stmt.limit.unwrap_or(DEFAULT_SELECT_LIMIT)).unwrap_or(u32::MAX);
349
350 filter_strategy = resolve_filter_strategy(
351 selectivity,
352 has_vector_search,
353 ef_search,
354 candidates,
355 stats,
356 );
357
358 nodes.push(PlanNode::Filter(FilterPlan {
359 conditions: filter_conditions.join(" AND "),
360 selectivity,
361 estimated_rows,
362 estimation_method,
363 }));
364 }
365
366 filter_strategy
367 }
368
369 fn append_post_filter_nodes(nodes: &mut Vec<PlanNode>, stmt: &SelectStatement) {
373 for join in &stmt.joins {
374 nodes.push(PlanNode::Join(JoinPlanNode {
375 join_type: format!("{:?}", join.join_type),
376 table: join.table.clone(),
377 }));
378 }
379 if let Some(ref group_by) = stmt.group_by {
380 nodes.push(PlanNode::GroupBy(GroupByPlan {
381 columns: group_by.columns.clone(),
382 }));
383 }
384 let functions = Self::aggregate_function_names(&stmt.columns);
385 if !functions.is_empty() {
386 nodes.push(PlanNode::Aggregate(AggregatePlan { functions }));
387 }
388 if let Some(ref order_by) = stmt.order_by {
389 let keys = order_by
390 .iter()
391 .map(|o| {
392 let (col, dir) = o.to_display_pair();
393 format!("{col} {dir}")
394 })
395 .collect();
396 nodes.push(PlanNode::Sort(SortPlan { keys }));
397 }
398 }
399
400 fn aggregate_function_names(columns: &crate::velesql::ast::SelectColumns) -> Vec<String> {
403 use crate::velesql::ast::SelectColumns;
404 let aggregations = match columns {
405 SelectColumns::Aggregations(aggs) => aggs.as_slice(),
406 SelectColumns::Mixed { aggregations, .. } => aggregations.as_slice(),
407 _ => &[],
408 };
409 aggregations
410 .iter()
411 .map(|a| format!("{:?}", a.function_type))
412 .collect()
413 }
414
415 fn push_pagination_nodes(
419 nodes: &mut Vec<PlanNode>,
420 stmt: &SelectStatement,
421 implicit_limit: bool,
422 ) {
423 if let Some(offset) = stmt.offset {
424 nodes.push(PlanNode::Offset(OffsetPlan { count: offset }));
425 }
426 Self::push_limit_node(nodes, stmt, implicit_limit);
427 }
428
429 fn push_limit_node(nodes: &mut Vec<PlanNode>, stmt: &SelectStatement, implicit_limit: bool) {
436 let (count, is_default) = match (stmt.limit, implicit_limit) {
437 (Some(limit), _) => (limit, false),
438 (None, true) => (DEFAULT_SELECT_LIMIT, true),
439 (None, false) => return,
440 };
441 nodes.push(PlanNode::Limit(LimitPlan { count, is_default }));
442 }
443
444 fn analyze_condition(
446 condition: &Condition,
447 has_vector_search: &mut bool,
448 filter_conditions: &mut Vec<String>,
449 ) {
450 match condition {
451 Condition::VectorSearch(_)
452 | Condition::VectorFusedSearch(_)
453 | Condition::SparseVectorSearch(_)
454 | Condition::Similarity(_) => {
455 *has_vector_search = true;
456 }
457 Condition::And(left, right) | Condition::Or(left, right) => {
458 Self::analyze_condition(left, has_vector_search, filter_conditions);
459 Self::analyze_condition(right, has_vector_search, filter_conditions);
460 }
461 Condition::Not(inner) | Condition::Group(inner) => {
462 Self::analyze_condition(inner, has_vector_search, filter_conditions);
463 }
464 leaf => {
465 if let Some(desc) = Self::describe_leaf_condition(leaf) {
466 filter_conditions.push(desc);
467 }
468 }
469 }
470 }
471
472 fn describe_leaf_condition(condition: &Condition) -> Option<String> {
476 let desc = match condition {
477 Condition::Comparison(cmp) => {
478 format!("{} {} ?", cmp.column, cmp.operator.as_str())
479 }
480 Condition::In(inc) => {
481 let op = if inc.negated { "NOT IN" } else { "IN" };
482 format!("{} {op} (...)", inc.column)
483 }
484 Condition::Between(btw) => format!("{} BETWEEN ? AND ?", btw.column),
485 Condition::Like(lk) => format!("{} LIKE ?", lk.column),
486 Condition::IsNull(isn) => {
487 let op = if isn.is_null {
488 "IS NULL"
489 } else {
490 "IS NOT NULL"
491 };
492 format!("{} {op}", isn.column)
493 }
494 Condition::Match(m) => format!("{} MATCH ?", m.column),
495 Condition::ContainsText(ct) => format!("{} CONTAINS_TEXT ?", ct.column),
496 Condition::GraphMatch(_) => "MATCH (...)".to_string(),
497 Condition::Contains(cc) => {
498 let mode_str = match cc.mode {
499 crate::velesql::ContainsMode::Single => "CONTAINS",
500 crate::velesql::ContainsMode::Any => "CONTAINS ANY",
501 crate::velesql::ContainsMode::All => "CONTAINS ALL",
502 };
503 format!("{} {mode_str} ?", cc.column)
504 }
505 Condition::GeoDistance(gd) => format!(
506 "GEO_DISTANCE({}, {}, {}) {} ?",
507 gd.column,
508 gd.lat,
509 gd.lng,
510 gd.operator.as_str()
511 ),
512 Condition::GeoBbox(gb) => format!("GEO_BBOX({}, ...)", gb.column),
513 _ => return None,
514 };
515 Some(desc)
516 }
517
518 fn extract_index_lookup(
519 condition: &Condition,
520 indexed_fields: &HashSet<String>,
521 ) -> Option<(String, String)> {
522 if let Condition::Comparison(cmp) = condition {
523 if cmp.operator == crate::velesql::CompareOp::Eq && indexed_fields.contains(&cmp.column)
524 {
525 return Some((cmp.column.clone(), format!("{:?}", cmp.value)));
526 }
527 }
528 if let Condition::In(inc) = condition {
529 if indexed_fields.contains(&inc.column) {
530 let op = if inc.negated { "NOT IN" } else { "IN" };
531 return Some((inc.column.clone(), format!("{op} (...)")));
532 }
533 }
534 None
535 }
536
537 pub(crate) fn estimate_selectivity(conditions: &[String]) -> f64 {
539 node_stats::estimate_selectivity(conditions, None)
540 }
541
542 #[cfg(all(test, feature = "persistence"))]
545 pub(crate) fn node_cost(node: &PlanNode) -> f64 {
546 node_stats::node_cost(node)
547 }
548
549 fn extract_strategy_info(
551 strategy: &MatchExecutionStrategy,
552 ) -> (Vec<String>, u32, bool, Option<f32>) {
553 match strategy {
554 MatchExecutionStrategy::GraphFirst {
555 start_labels,
556 max_depth,
557 } => (start_labels.clone(), *max_depth, false, None),
558 MatchExecutionStrategy::VectorFirst { threshold, .. } => {
559 (Vec::new(), 1, true, Some(*threshold))
560 }
561 MatchExecutionStrategy::Parallel {
562 graph_hint,
563 vector_hint,
564 } => {
565 let (labels, depth) = match graph_hint.as_ref() {
566 MatchExecutionStrategy::GraphFirst {
567 start_labels,
568 max_depth,
569 } => (start_labels.clone(), *max_depth),
570 _ => (Vec::new(), 1),
571 };
572 let threshold = match vector_hint.as_ref() {
573 MatchExecutionStrategy::VectorFirst { threshold, .. } => Some(*threshold),
574 _ => None,
575 };
576 (labels, depth, true, threshold)
577 }
578 }
579 }
580}