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)]
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
57fn 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
72fn 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 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
106fn 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 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
142fn 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
165fn 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
190fn 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
215fn core_plan_steps(plan: &QueryPlan) -> Vec<ExplainStep> {
220 plan.to_plan_steps().iter().map(ExplainStep::from).collect()
221}
222
223fn 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
242pub(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}