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