1use 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#[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_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
100fn 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(&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
143fn 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
166fn 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
191fn 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
216fn 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
346fn 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
365pub(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
380fn 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#[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}