Skip to main content

velesdb_server/handlers/query/
explain.rs

1//! EXPLAIN query handler and plan building logic.
2
3use axum::{extract::State, response::IntoResponse, Json};
4use std::sync::Arc;
5use velesdb_core::velesql::{Condition, SelectColumns};
6
7use crate::types::{ExplainCost, ExplainFeatures, ExplainRequest, ExplainResponse, ExplainStep};
8use crate::AppState;
9
10use super::velesql_helpers::{parse_and_validate, velesql_collection_not_found};
11
12/// Explain a VelesQL query without executing it (EPIC-058 US-002).
13///
14/// Returns the query plan, estimated costs, and detected features.
15#[utoipa::path(
16    post,
17    path = "/query/explain",
18    tag = "query",
19    request_body = ExplainRequest,
20    responses(
21        (status = 200, description = "Query plan", body = ExplainResponse),
22        (status = 400, description = "Query syntax error", body = crate::types::QueryErrorResponse),
23        (status = 404, description = "Collection not found", body = crate::types::VelesqlErrorResponse)
24    )
25)]
26#[allow(clippy::unused_async, deprecated)]
27pub async fn explain(
28    State(state): State<Arc<AppState>>,
29    Json(req): Json<ExplainRequest>,
30) -> impl IntoResponse {
31    let parsed = match parse_and_validate(&req.query) {
32        Ok(q) => q,
33        Err(resp) => return resp,
34    };
35
36    let select = &parsed.select;
37
38    let collection_exists = state.db.get_collection(&select.from).is_some();
39    if !collection_exists && !select.from.is_empty() {
40        return velesql_collection_not_found(&select.from);
41    }
42
43    let features = detect_explain_features(select);
44    let plan = build_explain_plan(select, &features);
45    let estimated_cost = estimate_cost(features.has_vector_search);
46
47    let query_type = if parsed.is_match_query() {
48        "MATCH"
49    } else {
50        "SELECT"
51    };
52
53    let (cache_hit, plan_reuse_count) = state
54        .db
55        .explain_query(&parsed)
56        .ok()
57        .map_or((None, None), |qp| (qp.cache_hit, qp.plan_reuse_count));
58
59    Json(ExplainResponse {
60        query: req.query,
61        query_type: query_type.to_string(),
62        collection: select.from.clone(),
63        plan,
64        estimated_cost,
65        features,
66        cache_hit,
67        plan_reuse_count,
68    })
69    .into_response()
70}
71
72/// Detect query features from a SELECT statement for EXPLAIN output.
73fn detect_explain_features(select: &velesdb_core::velesql::SelectStatement) -> ExplainFeatures {
74    let has_vector_search = select
75        .where_clause
76        .as_ref()
77        .map(condition_has_vector_search)
78        .unwrap_or(false);
79
80    ExplainFeatures {
81        has_vector_search,
82        has_filter: select.where_clause.is_some() && !has_vector_search,
83        has_order_by: select.order_by.is_some(),
84        has_group_by: select.group_by.is_some(),
85        has_aggregation: match &select.columns {
86            SelectColumns::Aggregations(_) => true,
87            SelectColumns::Mixed { aggregations, .. } => !aggregations.is_empty(),
88            _ => false,
89        },
90        has_join: !select.joins.is_empty(),
91        has_fusion: select.fusion_clause.is_some(),
92        limit: select.limit,
93        offset: select.offset,
94    }
95}
96
97/// Build the execution plan steps for an EXPLAIN response.
98fn build_explain_plan(
99    select: &velesdb_core::velesql::SelectStatement,
100    features: &ExplainFeatures,
101) -> Vec<ExplainStep> {
102    let mut plan = Vec::new();
103    let mut step_num = 1;
104
105    plan.push(build_source_step(select, features, step_num));
106    step_num += 1;
107
108    append_filter_and_join_steps(select, features, &mut plan, &mut step_num);
109    append_aggregation_steps(features, &mut plan, &mut step_num);
110    append_pagination_step(select, &mut plan, step_num);
111
112    plan
113}
114
115fn build_source_step(
116    select: &velesdb_core::velesql::SelectStatement,
117    features: &ExplainFeatures,
118    step_num: usize,
119) -> ExplainStep {
120    if features.has_vector_search {
121        ExplainStep {
122            step: step_num,
123            operation: "VectorSearch".to_string(),
124            description: "ANN search using HNSW index with NEAR clause".to_string(),
125            estimated_rows: select.limit.map(|l| l as usize),
126        }
127    } else {
128        ExplainStep {
129            step: step_num,
130            operation: "FullScan".to_string(),
131            description: format!("Scan collection '{}'", select.from),
132            estimated_rows: None,
133        }
134    }
135}
136
137fn append_filter_and_join_steps(
138    select: &velesdb_core::velesql::SelectStatement,
139    features: &ExplainFeatures,
140    plan: &mut Vec<ExplainStep>,
141    step_num: &mut usize,
142) {
143    if features.has_filter {
144        plan.push(ExplainStep {
145            step: *step_num,
146            operation: "Filter".to_string(),
147            description: "Apply WHERE clause predicates".to_string(),
148            estimated_rows: None,
149        });
150        *step_num += 1;
151    }
152
153    for join in &select.joins {
154        plan.push(ExplainStep {
155            step: *step_num,
156            operation: format!("{:?}Join", join.join_type),
157            description: format!("Join with '{}'", join.table),
158            estimated_rows: None,
159        });
160        *step_num += 1;
161    }
162}
163
164fn append_aggregation_steps(
165    features: &ExplainFeatures,
166    plan: &mut Vec<ExplainStep>,
167    step_num: &mut usize,
168) {
169    if features.has_group_by {
170        plan.push(ExplainStep {
171            step: *step_num,
172            operation: "GroupBy".to_string(),
173            description: "Group rows by specified columns".to_string(),
174            estimated_rows: None,
175        });
176        *step_num += 1;
177    }
178
179    if features.has_aggregation {
180        plan.push(ExplainStep {
181            step: *step_num,
182            operation: "Aggregate".to_string(),
183            description: "Compute aggregate functions (COUNT, SUM, etc.)".to_string(),
184            estimated_rows: None,
185        });
186        *step_num += 1;
187    }
188
189    if features.has_order_by {
190        plan.push(ExplainStep {
191            step: *step_num,
192            operation: "Sort".to_string(),
193            description: "Sort results by ORDER BY clause".to_string(),
194            estimated_rows: None,
195        });
196        *step_num += 1;
197    }
198}
199
200fn append_pagination_step(
201    select: &velesdb_core::velesql::SelectStatement,
202    plan: &mut Vec<ExplainStep>,
203    step_num: usize,
204) {
205    if select.limit.is_some() || select.offset.is_some() {
206        plan.push(ExplainStep {
207            step: step_num,
208            operation: "Limit".to_string(),
209            description: format!(
210                "Apply LIMIT {} OFFSET {}",
211                select.limit.unwrap_or(0),
212                select.offset.unwrap_or(0)
213            ),
214            estimated_rows: select.limit.map(|l| l as usize),
215        });
216    }
217}
218
219/// Estimate execution cost based on query features.
220fn estimate_cost(has_vector_search: bool) -> ExplainCost {
221    ExplainCost {
222        uses_index: has_vector_search,
223        index_name: if has_vector_search {
224            Some("HNSW".to_string())
225        } else {
226            None
227        },
228        selectivity: if has_vector_search { 0.01 } else { 1.0 },
229        complexity: if has_vector_search {
230            "O(log n)"
231        } else {
232            "O(n)"
233        }
234        .to_string(),
235    }
236}
237
238/// Check if a condition contains vector search.
239pub(super) fn condition_has_vector_search(cond: &Condition) -> bool {
240    match cond {
241        Condition::VectorSearch(_)
242        | Condition::VectorFusedSearch { .. }
243        | Condition::Similarity(_) => true,
244        Condition::And(left, right) | Condition::Or(left, right) => {
245            condition_has_vector_search(left) || condition_has_vector_search(right)
246        }
247        Condition::Group(inner) | Condition::Not(inner) => condition_has_vector_search(inner),
248        _ => false,
249    }
250}