Skip to main content

velesdb_core/velesql/explain/
plan_builder.rs

1//! Plan construction logic for `VelesQL` EXPLAIN.
2//!
3//! Contains `impl QueryPlan` methods for building plans from SELECT statements,
4//! MATCH clauses, and related query structures.
5
6use 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    /// Creates a new query plan from a SELECT statement.
25    #[must_use]
26    pub fn from_select(stmt: &SelectStatement) -> Self {
27        Self::from_select_with_stats(stmt, &HashSet::new(), None)
28    }
29
30    /// Creates a new query plan from SELECT with known indexed metadata fields.
31    #[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    /// Creates a query plan with access to calibrated collection statistics.
40    ///
41    /// When `stats` is `Some`, cost and filter-strategy decisions use the
42    /// calibrated `CostEstimator` pipeline (issue #471). When `None`, falls
43    /// back bit-for-bit to the heuristic path so legacy tests and callers
44    /// without a resolved collection keep working.
45    #[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    /// Shared SELECT plan construction.
55    ///
56    /// `implicit_limit` controls whether the engine default
57    /// [`DEFAULT_SELECT_LIMIT`] is surfaced as a Limit node when the statement
58    /// has no explicit LIMIT. Plain SELECT statements pass `true`; MATCH and
59    /// compound queries pass `false` (no implicit limit applies to them).
60    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    /// Creates a full query plan from a `Query`, including LET bindings (issue #471).
99    #[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    /// Creates a full query plan from a `Query`, with optional collection stats
105    /// for calibrated cost estimation (issue #471).
106    #[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        // MATCH and compound queries have no implicit default LIMIT.
113        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    /// Creates a new query plan from a MATCH clause (EPIC-046 US-004).
121    #[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    /// Variant of `assemble_plan` with optional calibrated `CollectionStats`.
167    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    /// Default `ef_search` when the WITH clause does not specify one.
194    const DEFAULT_EF_SEARCH: u32 = 100;
195
196    /// Builds the primary scan node based on search type.
197    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    /// Reads `ef_search` from the WITH clause, falling back to [`Self::DEFAULT_EF_SEARCH`].
233    #[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    /// Extracts WITH clause options as display pairs (issue #471).
242    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    /// Extracts FUSION clause info for EXPLAIN display (issue #471).
253    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    /// Formats fusion weights into a human-readable string.
271    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    /// Formats LET bindings as `"name = expr"` strings (issue #471).
293    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    /// Variant of `append_filter_nodes` with access to the calibrated
301    /// `CostEstimator` (issue #471).
302    ///
303    /// Selectivity and filter strategy use histogram data when `stats` is
304    /// `Some`. When `None`, the historical heuristic (selectivity from
305    /// condition count, 0.1 threshold) is preserved bit-for-bit.
306    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            // Reason: plan_builder owns stmt so the real ef_search/candidates
321            // are the same values used in `build_scan_node` above
322            // (Devin finding 4). These values drive the pre/post-filter cost
323            // comparison so it reflects the user's actual WITH clause instead
324            // of a fixed k = 10.
325            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    /// Appends post-filter pipeline nodes (JOIN, GROUP BY, aggregation, ORDER
349    /// BY) in the order they execute, mirroring the previous server-side
350    /// reconstruction so the single-sourced plan is step-compatible.
351    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    /// Returns the aggregate function names in SELECT-list order, or an empty
380    /// vec when the projection has no aggregates.
381    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    /// Appends the OFFSET (when present) and LIMIT pagination nodes last, so the
395    /// pipeline order is scan → filter → joins → group → aggregate → sort →
396    /// offset → limit.
397    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    /// Pushes the Limit node for a statement.
409    ///
410    /// Without an explicit LIMIT, plain SELECT statements (`implicit_limit ==
411    /// true`) surface the engine default [`DEFAULT_SELECT_LIMIT`] so EXPLAIN
412    /// matches what execution actually returns. MATCH and compound queries
413    /// (`implicit_limit == false`) keep their unlimited semantics: no node.
414    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    /// Analyzes a condition to extract vector search and filter info.
424    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    /// Renders a non-composite condition as a short human-readable string for
452    /// the EXPLAIN plan filter list. Returns `None` for vector/composite
453    /// variants — those are handled in [`analyze_condition`] itself.
454    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    /// Estimates selectivity (placeholder - would need statistics in production).
517    pub(crate) fn estimate_selectivity(conditions: &[String]) -> f64 {
518        node_stats::estimate_selectivity(conditions, None)
519    }
520
521    /// Returns the heuristic cost for a single plan node.
522    #[cfg(test)]
523    pub(crate) fn node_cost(node: &PlanNode) -> f64 {
524        node_stats::node_cost(node)
525    }
526
527    /// Extracts traversal parameters from a `MatchExecutionStrategy`.
528    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}