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::{
8    ActualStatsResponse, ExplainCost, ExplainFeatures, ExplainRequest, ExplainResponse,
9    ExplainStep, NodeStatsResponse,
10};
11use crate::AppState;
12
13use super::velesql_helpers::{parse_and_validate, velesql_collection_not_found, velesql_error};
14use axum::http::StatusCode;
15use velesdb_core::Error as CoreError;
16
17/// Explain a VelesQL query, optionally executing it with instrumentation.
18///
19/// When `analyze` is false (default), returns the estimated plan only.
20/// When `analyze` is true, executes the query and returns actual statistics.
21#[utoipa::path(
22    post,
23    path = "/query/explain",
24    tag = "query",
25    request_body = ExplainRequest,
26    responses(
27        (status = 200, description = "Query plan", body = ExplainResponse),
28        (status = 400, description = "Query syntax error", body = crate::types::QueryErrorResponse),
29        (status = 422, description = "Query validation/execution error", body = crate::types::VelesqlErrorResponse),
30        (status = 404, description = "Collection not found", body = crate::types::VelesqlErrorResponse)
31    )
32)]
33#[allow(clippy::unused_async)]
34pub async fn explain(
35    State(state): State<Arc<AppState>>,
36    Json(req): Json<ExplainRequest>,
37) -> impl IntoResponse {
38    let parsed = match parse_and_validate(&req.query) {
39        Ok(q) => q,
40        Err(resp) => return resp,
41    };
42
43    let select = &parsed.select;
44
45    let collection_exists = state.db.get_any_collection(&select.from).is_some();
46    if !collection_exists && !select.from.is_empty() {
47        return velesql_collection_not_found(&select.from);
48    }
49
50    if req.analyze {
51        return explain_with_analyze(&state, &req, &parsed);
52    }
53
54    explain_plan_only(&state, &req, &parsed)
55}
56
57/// Build an EXPLAIN-only response (no execution).
58fn explain_plan_only(
59    state: &AppState,
60    req: &ExplainRequest,
61    parsed: &velesdb_core::velesql::Query,
62) -> axum::response::Response {
63    let select = &parsed.select;
64    let features = detect_explain_features(select);
65    let mut plan = build_explain_plan(select, &features);
66    let estimated_cost = estimate_cost(features.has_vector_search);
67    let query_type = if parsed.is_match_query() {
68        "MATCH"
69    } else {
70        "SELECT"
71    };
72
73    let (cache_hit, plan_reuse_count) =
74        state
75            .db
76            .explain_query(parsed)
77            .ok()
78            .map_or((None, None), |qp| {
79                merge_core_estimation(&mut plan, &qp);
80                (qp.cache_hit, qp.plan_reuse_count)
81            });
82
83    Json(ExplainResponse {
84        query: req.query.clone(),
85        query_type: query_type.to_string(),
86        collection: select.from.clone(),
87        plan,
88        estimated_cost,
89        features,
90        cache_hit,
91        plan_reuse_count,
92        estimated_cost_ms: None,
93        actual_time_ms: None,
94        actual_stats: None,
95        node_stats: None,
96    })
97    .into_response()
98}
99
100/// Build an EXPLAIN ANALYZE response (with execution and actual stats).
101fn explain_with_analyze(
102    state: &AppState,
103    req: &ExplainRequest,
104    parsed: &velesdb_core::velesql::Query,
105) -> axum::response::Response {
106    let select = &parsed.select;
107    let features = detect_explain_features(select);
108    let mut plan = build_explain_plan(select, &features);
109    let estimated_cost = estimate_cost(features.has_vector_search);
110    let query_type = if parsed.is_match_query() {
111        "MATCH"
112    } else {
113        "SELECT"
114    };
115
116    let output = match run_analyze_query(state, parsed, &req.params) {
117        Ok(o) => o,
118        Err(resp) => return *resp,
119    };
120
121    // Merge core estimation metadata into server plan (graceful: already have output).
122    merge_core_estimation(&mut plan, &output.plan);
123
124    let (actual_stats_resp, actual_time, node_stats_resp) = extract_analyze_stats(&output);
125
126    Json(ExplainResponse {
127        query: req.query.clone(),
128        query_type: query_type.to_string(),
129        collection: select.from.clone(),
130        plan,
131        estimated_cost,
132        features,
133        cache_hit: output.plan.cache_hit,
134        plan_reuse_count: output.plan.plan_reuse_count,
135        estimated_cost_ms: Some(output.plan.estimated_cost_ms),
136        actual_time_ms: actual_time,
137        actual_stats: actual_stats_resp,
138        node_stats: node_stats_resp,
139    })
140    .into_response()
141}
142
143/// Runs the core `explain_analyze_query` call, mapping core errors to the
144/// matching HTTP responses. Returns `Ok(output)` on success or `Err(response)`
145/// with the error already rendered.
146fn run_analyze_query(
147    state: &AppState,
148    parsed: &velesdb_core::velesql::Query,
149    params: &std::collections::HashMap<String, serde_json::Value>,
150) -> std::result::Result<velesdb_core::velesql::ExplainOutput, Box<axum::response::Response>> {
151    match state.db.explain_analyze_query(parsed, params) {
152        Ok(o) => Ok(o),
153        Err(CoreError::CollectionNotFound(name)) => {
154            Err(Box::new(velesql_collection_not_found(&name)))
155        }
156        Err(e) => Err(Box::new(velesql_error(
157            StatusCode::UNPROCESSABLE_ENTITY,
158            "VELESQL_EXPLAIN_ANALYZE_ERROR",
159            &e.to_string(),
160            "Validate query semantics and parameter types against the target collection",
161            None,
162        ))),
163    }
164}
165
166/// Splits the optional `actual_stats` + `node_stats` of an EXPLAIN ANALYZE
167/// output into the three response-side options consumed by
168/// [`ExplainResponse`].
169fn extract_analyze_stats(
170    output: &velesdb_core::velesql::ExplainOutput,
171) -> (
172    Option<ActualStatsResponse>,
173    Option<f64>,
174    Option<Vec<NodeStatsResponse>>,
175) {
176    let Some(ref stats) = output.actual_stats else {
177        return (None, None, None);
178    };
179    let ns: Vec<NodeStatsResponse> = output
180        .node_stats
181        .iter()
182        .map(NodeStatsResponse::from)
183        .collect();
184    (
185        Some(ActualStatsResponse::from(stats)),
186        Some(stats.actual_time_ms),
187        Some(ns),
188    )
189}
190
191/// Detect query features from a SELECT statement for EXPLAIN output.
192fn detect_explain_features(select: &velesdb_core::velesql::SelectStatement) -> ExplainFeatures {
193    let has_vector_search = select
194        .where_clause
195        .as_ref()
196        .map(condition_has_vector_search)
197        .unwrap_or(false);
198
199    ExplainFeatures {
200        has_vector_search,
201        has_filter: select.where_clause.is_some() && !has_vector_search,
202        has_order_by: select.order_by.is_some(),
203        has_group_by: select.group_by.is_some(),
204        has_aggregation: match &select.columns {
205            SelectColumns::Aggregations(_) => true,
206            SelectColumns::Mixed { aggregations, .. } => !aggregations.is_empty(),
207            _ => false,
208        },
209        has_join: !select.joins.is_empty(),
210        has_fusion: select.fusion_clause.is_some(),
211        limit: select.limit,
212        offset: select.offset,
213    }
214}
215
216/// Build the execution plan steps for an EXPLAIN response.
217fn build_explain_plan(
218    select: &velesdb_core::velesql::SelectStatement,
219    features: &ExplainFeatures,
220) -> Vec<ExplainStep> {
221    let mut plan = Vec::new();
222    let mut step_num = 1;
223
224    plan.push(build_source_step(select, features, step_num));
225    step_num += 1;
226
227    append_filter_and_join_steps(select, features, &mut plan, &mut step_num);
228    append_aggregation_steps(features, &mut plan, &mut step_num);
229    append_pagination_step(select, &mut plan, step_num);
230
231    plan
232}
233
234fn build_source_step(
235    select: &velesdb_core::velesql::SelectStatement,
236    features: &ExplainFeatures,
237    step_num: usize,
238) -> ExplainStep {
239    if features.has_vector_search {
240        ExplainStep {
241            step: step_num,
242            operation: "VectorSearch".to_string(),
243            description: "ANN search using HNSW index with NEAR clause".to_string(),
244            estimated_rows: select.limit.map(|l| l as usize),
245            estimation_method: None,
246        }
247    } else {
248        ExplainStep {
249            step: step_num,
250            operation: "FullScan".to_string(),
251            description: format!("Scan collection '{}'", select.from),
252            estimated_rows: None,
253            estimation_method: None,
254        }
255    }
256}
257
258fn append_filter_and_join_steps(
259    select: &velesdb_core::velesql::SelectStatement,
260    features: &ExplainFeatures,
261    plan: &mut Vec<ExplainStep>,
262    step_num: &mut usize,
263) {
264    if features.has_filter {
265        plan.push(ExplainStep {
266            step: *step_num,
267            operation: "Filter".to_string(),
268            description: "Apply WHERE clause predicates".to_string(),
269            estimated_rows: None,
270            estimation_method: None,
271        });
272        *step_num += 1;
273    }
274
275    for join in &select.joins {
276        plan.push(ExplainStep {
277            step: *step_num,
278            operation: format!("{:?}Join", join.join_type),
279            description: format!("Join with '{}'", join.table),
280            estimated_rows: None,
281            estimation_method: None,
282        });
283        *step_num += 1;
284    }
285}
286
287fn append_aggregation_steps(
288    features: &ExplainFeatures,
289    plan: &mut Vec<ExplainStep>,
290    step_num: &mut usize,
291) {
292    if features.has_group_by {
293        plan.push(ExplainStep {
294            step: *step_num,
295            operation: "GroupBy".to_string(),
296            description: "Group rows by specified columns".to_string(),
297            estimated_rows: None,
298            estimation_method: None,
299        });
300        *step_num += 1;
301    }
302
303    if features.has_aggregation {
304        plan.push(ExplainStep {
305            step: *step_num,
306            operation: "Aggregate".to_string(),
307            description: "Compute aggregate functions (COUNT, SUM, etc.)".to_string(),
308            estimated_rows: None,
309            estimation_method: None,
310        });
311        *step_num += 1;
312    }
313
314    if features.has_order_by {
315        plan.push(ExplainStep {
316            step: *step_num,
317            operation: "Sort".to_string(),
318            description: "Sort results by ORDER BY clause".to_string(),
319            estimated_rows: None,
320            estimation_method: None,
321        });
322        *step_num += 1;
323    }
324}
325
326fn append_pagination_step(
327    select: &velesdb_core::velesql::SelectStatement,
328    plan: &mut Vec<ExplainStep>,
329    step_num: usize,
330) {
331    if select.limit.is_some() || select.offset.is_some() {
332        plan.push(ExplainStep {
333            step: step_num,
334            operation: "Limit".to_string(),
335            description: format!(
336                "Apply LIMIT {} OFFSET {}",
337                select.limit.unwrap_or(0),
338                select.offset.unwrap_or(0)
339            ),
340            estimated_rows: select.limit.map(|l| l as usize),
341            estimation_method: None,
342        });
343    }
344}
345
346/// Estimate execution cost based on query features.
347fn estimate_cost(has_vector_search: bool) -> ExplainCost {
348    ExplainCost {
349        uses_index: has_vector_search,
350        index_name: if has_vector_search {
351            Some("HNSW".to_string())
352        } else {
353            None
354        },
355        selectivity: if has_vector_search { 0.01 } else { 1.0 },
356        complexity: if has_vector_search {
357            "O(log n)"
358        } else {
359            "O(n)"
360        }
361        .to_string(),
362    }
363}
364
365/// Check if a condition contains vector search.
366pub(super) fn condition_has_vector_search(cond: &Condition) -> bool {
367    match cond {
368        Condition::VectorSearch(_)
369        | Condition::VectorFusedSearch { .. }
370        | Condition::SparseVectorSearch(_)
371        | Condition::Similarity(_) => true,
372        Condition::And(left, right) | Condition::Or(left, right) => {
373            condition_has_vector_search(left) || condition_has_vector_search(right)
374        }
375        Condition::Group(inner) | Condition::Not(inner) => condition_has_vector_search(inner),
376        _ => false,
377    }
378}
379
380// ---------------------------------------------------------------------------
381// Core plan merge helpers (Task 2.2)
382// ---------------------------------------------------------------------------
383
384/// Recursively extracts the first `FilterPlan` from a core `PlanNode` tree.
385fn extract_filter_plan(
386    node: &velesdb_core::velesql::PlanNode,
387) -> Option<&velesdb_core::velesql::FilterPlan> {
388    match node {
389        velesdb_core::velesql::PlanNode::Filter(fp) => Some(fp),
390        velesdb_core::velesql::PlanNode::Sequence(nodes) => {
391            nodes.iter().find_map(extract_filter_plan)
392        }
393        _ => None,
394    }
395}
396
397/// Merges core `FilterPlan` estimation data into server `ExplainStep` entries.
398///
399/// Copies `estimated_rows` and `estimation_method` from the core plan's
400/// `FilterPlan` into every server step whose `operation` is `"Filter"`.
401#[allow(clippy::cast_possible_truncation)]
402fn merge_core_estimation(plan: &mut [ExplainStep], core_plan: &velesdb_core::velesql::QueryPlan) {
403    if let Some(fp) = extract_filter_plan(&core_plan.root) {
404        for step in plan.iter_mut() {
405            if step.operation == "Filter" {
406                step.estimated_rows = fp.estimated_rows.map(|r| r as usize);
407                step.estimation_method = fp.estimation_method.clone();
408            }
409        }
410    }
411}