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::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    /// Creates a new query plan from a SELECT statement.
23    #[must_use]
24    pub fn from_select(stmt: &SelectStatement) -> Self {
25        Self::from_select_with_stats(stmt, &HashSet::new(), None)
26    }
27
28    /// Creates a new query plan from SELECT with known indexed metadata fields.
29    #[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    /// Creates a query plan with access to calibrated collection statistics.
38    ///
39    /// When `stats` is `Some`, cost and filter-strategy decisions use the
40    /// calibrated `CostEstimator` pipeline (issue #471). When `None`, falls
41    /// back bit-for-bit to the heuristic path so legacy tests and callers
42    /// without a resolved collection keep working.
43    #[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    /// Shared SELECT plan construction.
53    ///
54    /// `implicit_limit` controls whether the engine default
55    /// [`DEFAULT_SELECT_LIMIT`] is surfaced as a Limit node when the statement
56    /// has no explicit LIMIT. Plain SELECT statements pass `true`; MATCH and
57    /// compound queries pass `false` (no implicit limit applies to them).
58    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    /// Creates a full query plan from a `Query`, including LET bindings (issue #471).
97    #[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    /// Creates a full query plan from a `Query`, with optional collection stats
103    /// for calibrated cost estimation (issue #471).
104    #[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    /// Creates a full query plan from a `Query`, threading both calibrated
114    /// `CoreCollectionStats` (SELECT cost estimation) and graph
115    /// `CollectionStats` (MATCH traversal-strategy selection).
116    ///
117    /// MATCH queries route through [`Self::from_match`] so the EXPLAIN/exec
118    /// path emits a `MatchTraversal` node with a real strategy instead of a
119    /// bare `TableScan` mislabeled MATCH (backlog #14). `match_stats` is `None`
120    /// on the pure-AST path (default graph stats); the Database layer supplies
121    /// the live graph stats from `compute_match_collection_stats`.
122    #[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            // Compound queries have no implicit default LIMIT either.
134            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    /// Creates a new query plan from a MATCH clause (EPIC-046 US-004).
142    #[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    /// Variant of `assemble_plan` with optional calibrated `CollectionStats`.
188    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    /// Default `ef_search` when the WITH clause does not specify one.
215    const DEFAULT_EF_SEARCH: u32 = 100;
216
217    /// Builds the primary scan node based on search type.
218    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    /// Reads `ef_search` from the WITH clause, falling back to [`Self::DEFAULT_EF_SEARCH`].
254    #[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    /// Extracts WITH clause options as display pairs (issue #471).
263    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    /// Extracts FUSION clause info for EXPLAIN display (issue #471).
274    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    /// Formats fusion weights into a human-readable string.
292    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    /// Formats LET bindings as `"name = expr"` strings (issue #471).
314    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    /// Variant of `append_filter_nodes` with access to the calibrated
322    /// `CostEstimator` (issue #471).
323    ///
324    /// Selectivity and filter strategy use histogram data when `stats` is
325    /// `Some`. When `None`, the historical heuristic (selectivity from
326    /// condition count, 0.1 threshold) is preserved bit-for-bit.
327    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            // Reason: plan_builder owns stmt so the real ef_search/candidates
342            // are the same values used in `build_scan_node` above
343            // (Devin finding 4). These values drive the pre/post-filter cost
344            // comparison so it reflects the user's actual WITH clause instead
345            // of a fixed k = 10.
346            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    /// Appends post-filter pipeline nodes (JOIN, GROUP BY, aggregation, ORDER
370    /// BY) in the order they execute, mirroring the previous server-side
371    /// reconstruction so the single-sourced plan is step-compatible.
372    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    /// Returns the aggregate function names in SELECT-list order, or an empty
401    /// vec when the projection has no aggregates.
402    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    /// Appends the OFFSET (when present) and LIMIT pagination nodes last, so the
416    /// pipeline order is scan → filter → joins → group → aggregate → sort →
417    /// offset → limit.
418    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    /// Pushes the Limit node for a statement.
430    ///
431    /// Without an explicit LIMIT, plain SELECT statements (`implicit_limit ==
432    /// true`) surface the engine default [`DEFAULT_SELECT_LIMIT`] so EXPLAIN
433    /// matches what execution actually returns. MATCH and compound queries
434    /// (`implicit_limit == false`) keep their unlimited semantics: no node.
435    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    /// Analyzes a condition to extract vector search and filter info.
445    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    /// Renders a non-composite condition as a short human-readable string for
473    /// the EXPLAIN plan filter list. Returns `None` for vector/composite
474    /// variants — those are handled in [`analyze_condition`] itself.
475    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    /// Estimates selectivity (placeholder - would need statistics in production).
538    pub(crate) fn estimate_selectivity(conditions: &[String]) -> f64 {
539        node_stats::estimate_selectivity(conditions, None)
540    }
541
542    /// Returns the heuristic cost for a single plan node.
543    // Test-only shim; its sole consumer (`explain_tests`) is persistence-gated (P1.4).
544    #[cfg(all(test, feature = "persistence"))]
545    pub(crate) fn node_cost(node: &PlanNode) -> f64 {
546        node_stats::node_cost(node)
547    }
548
549    /// Extracts traversal parameters from a `MatchExecutionStrategy`.
550    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}