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, QueryPlan, 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)]
33pub async fn explain(
34    State(state): State<Arc<AppState>>,
35    Json(req): Json<ExplainRequest>,
36) -> impl IntoResponse {
37    let parsed = match parse_and_validate(&req.query) {
38        Ok(q) => q,
39        Err(resp) => return resp,
40    };
41
42    // Plan building and (with `analyze`) query execution call synchronous
43    // core code that can take locks — run them on the blocking pool so the
44    // async workers stay responsive.
45    let state_clone = Arc::clone(&state);
46    crate::handlers::helpers::run_blocking(move || explain_parsed(&state_clone, &req, &parsed))
47        .await
48        .unwrap_or_else(|resp| resp)
49}
50
51/// Synchronous post-parse body of [`explain`]. Runs on the blocking pool.
52fn explain_parsed(
53    state: &AppState,
54    req: &ExplainRequest,
55    parsed: &velesdb_core::velesql::Query,
56) -> axum::response::Response {
57    let select = &parsed.select;
58
59    let collection_exists = state.db.get_any_collection(&select.from).is_some();
60    if !collection_exists && !select.from.is_empty() {
61        return velesql_collection_not_found(&select.from);
62    }
63
64    if req.analyze {
65        return explain_with_analyze(state, req, parsed);
66    }
67
68    explain_plan_only(state, req, parsed)
69}
70
71/// Computes the EXPLAIN preamble shared by the plan-only and ANALYZE paths:
72/// detected query features, estimated cost, and the query-type label.
73fn explain_preamble(
74    parsed: &velesdb_core::velesql::Query,
75) -> (ExplainFeatures, ExplainCost, &'static str) {
76    let features = detect_explain_features(&parsed.select);
77    let estimated_cost = estimate_cost(features.has_vector_search);
78    let query_type = if parsed.is_match_query() {
79        "MATCH"
80    } else {
81        "SELECT"
82    };
83    (features, estimated_cost, query_type)
84}
85
86/// Build an EXPLAIN-only response (no execution).
87fn explain_plan_only(
88    state: &AppState,
89    req: &ExplainRequest,
90    parsed: &velesdb_core::velesql::Query,
91) -> axum::response::Response {
92    let select = &parsed.select;
93    let (features, estimated_cost, query_type) = explain_preamble(parsed);
94
95    // Single-sourced from core: the plan steps come from the canonical
96    // `QueryPlan`, not a server-side AST reconstruction. The DB-less fallback
97    // keeps EXPLAIN working when the collection cannot be resolved.
98    let (plan, cache_hit, plan_reuse_count) = match state.db.explain_query(parsed) {
99        Ok(qp) => (core_plan_steps(&qp), qp.cache_hit, qp.plan_reuse_count),
100        Err(_) => (core_plan_steps(&QueryPlan::from_query(parsed)), None, None),
101    };
102
103    Json(ExplainResponse {
104        query: req.query.clone(),
105        query_type: query_type.to_string(),
106        collection: select.from.clone(),
107        plan,
108        estimated_cost,
109        features,
110        cache_hit,
111        plan_reuse_count,
112        estimated_cost_ms: None,
113        actual_time_ms: None,
114        actual_stats: None,
115        node_stats: None,
116    })
117    .into_response()
118}
119
120/// Build an EXPLAIN ANALYZE response (with execution and actual stats).
121fn explain_with_analyze(
122    state: &AppState,
123    req: &ExplainRequest,
124    parsed: &velesdb_core::velesql::Query,
125) -> axum::response::Response {
126    let select = &parsed.select;
127    let (features, estimated_cost, query_type) = explain_preamble(parsed);
128
129    let output = match run_analyze_query(state, parsed, &req.params) {
130        Ok(o) => o,
131        Err(resp) => return *resp,
132    };
133
134    // Single-sourced from core: steps derive from the executed plan.
135    let plan = core_plan_steps(&output.plan);
136
137    let (actual_stats_resp, actual_time, node_stats_resp) = extract_analyze_stats(&output);
138
139    Json(ExplainResponse {
140        query: req.query.clone(),
141        query_type: query_type.to_string(),
142        collection: select.from.clone(),
143        plan,
144        estimated_cost,
145        features,
146        cache_hit: output.plan.cache_hit,
147        plan_reuse_count: output.plan.plan_reuse_count,
148        estimated_cost_ms: Some(output.plan.estimated_cost_ms),
149        actual_time_ms: actual_time,
150        actual_stats: actual_stats_resp,
151        node_stats: node_stats_resp,
152    })
153    .into_response()
154}
155
156/// Runs the core `explain_analyze_query` call, mapping core errors to the
157/// matching HTTP responses. Returns `Ok(output)` on success or `Err(response)`
158/// with the error already rendered.
159fn run_analyze_query(
160    state: &AppState,
161    parsed: &velesdb_core::velesql::Query,
162    params: &std::collections::HashMap<String, serde_json::Value>,
163) -> std::result::Result<velesdb_core::velesql::ExplainOutput, Box<axum::response::Response>> {
164    match state.db.explain_analyze_query(parsed, params) {
165        Ok(o) => Ok(o),
166        Err(CoreError::CollectionNotFound(name)) => {
167            Err(Box::new(velesql_collection_not_found(&name)))
168        }
169        Err(e) => Err(Box::new(velesql_error(
170            StatusCode::UNPROCESSABLE_ENTITY,
171            "VELESQL_EXPLAIN_ANALYZE_ERROR",
172            &e.to_string(),
173            "Validate query semantics and parameter types against the target collection",
174            None,
175        ))),
176    }
177}
178
179/// Splits the optional `actual_stats` + `node_stats` of an EXPLAIN ANALYZE
180/// output into the three response-side options consumed by
181/// [`ExplainResponse`].
182fn extract_analyze_stats(
183    output: &velesdb_core::velesql::ExplainOutput,
184) -> (
185    Option<ActualStatsResponse>,
186    Option<f64>,
187    Option<Vec<NodeStatsResponse>>,
188) {
189    let Some(ref stats) = output.actual_stats else {
190        return (None, None, None);
191    };
192    let ns: Vec<NodeStatsResponse> = output
193        .node_stats
194        .iter()
195        .map(NodeStatsResponse::from)
196        .collect();
197    (
198        Some(ActualStatsResponse::from(stats)),
199        Some(stats.actual_time_ms),
200        Some(ns),
201    )
202}
203
204/// Detect query features from a SELECT statement for EXPLAIN output.
205fn detect_explain_features(select: &velesdb_core::velesql::SelectStatement) -> ExplainFeatures {
206    let has_vector_search = select
207        .where_clause
208        .as_ref()
209        .map(condition_has_vector_search)
210        .unwrap_or(false);
211
212    ExplainFeatures {
213        has_vector_search,
214        has_filter: select.where_clause.is_some() && !has_vector_search,
215        has_order_by: select.order_by.is_some(),
216        has_group_by: select.group_by.is_some(),
217        has_aggregation: match &select.columns {
218            SelectColumns::Aggregations(_) => true,
219            SelectColumns::Mixed { aggregations, .. } => !aggregations.is_empty(),
220            _ => false,
221        },
222        has_join: !select.joins.is_empty(),
223        has_fusion: select.fusion_clause.is_some(),
224        limit: select.limit,
225        offset: select.offset,
226    }
227}
228
229/// Maps a core [`QueryPlan`] into the REST [`ExplainStep`] list.
230///
231/// The plan steps are single-sourced from `velesdb-core` (`to_plan_steps`),
232/// so the server no longer reconstructs them from the parsed AST.
233fn core_plan_steps(plan: &QueryPlan) -> Vec<ExplainStep> {
234    plan.to_plan_steps().iter().map(ExplainStep::from).collect()
235}
236
237/// Estimate execution cost based on query features.
238fn estimate_cost(has_vector_search: bool) -> ExplainCost {
239    ExplainCost {
240        uses_index: has_vector_search,
241        index_name: if has_vector_search {
242            Some("HNSW".to_string())
243        } else {
244            None
245        },
246        selectivity: if has_vector_search { 0.01 } else { 1.0 },
247        complexity: if has_vector_search {
248            "O(log n)"
249        } else {
250            "O(n)"
251        }
252        .to_string(),
253    }
254}
255
256/// Check if a condition contains vector search.
257pub(super) fn condition_has_vector_search(cond: &Condition) -> bool {
258    match cond {
259        Condition::VectorSearch(_)
260        | Condition::VectorFusedSearch { .. }
261        | Condition::SparseVectorSearch(_)
262        | Condition::Similarity(_) => true,
263        Condition::And(left, right) | Condition::Or(left, right) => {
264            condition_has_vector_search(left) || condition_has_vector_search(right)
265        }
266        Condition::Group(inner) | Condition::Not(inner) => condition_has_vector_search(inner),
267        _ => false,
268    }
269}