velesdb_server/handlers/query/
explain.rs1use 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#[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 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
51fn 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
71fn 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
86fn 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 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
120fn 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 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
156fn 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
179fn 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
204fn 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
229fn core_plan_steps(plan: &QueryPlan) -> Vec<ExplainStep> {
234 plan.to_plan_steps().iter().map(ExplainStep::from).collect()
235}
236
237fn 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
256pub(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}