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