1use axum::{extract::State, response::IntoResponse, Json};
4use std::sync::Arc;
5use velesdb_core::velesql::{Condition, SelectColumns, DEFAULT_SELECT_LIMIT};
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(parsed, &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(parsed, &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 parsed: &velesdb_core::velesql::Query,
219 features: &ExplainFeatures,
220) -> Vec<ExplainStep> {
221 let select = &parsed.select;
222 let mut plan = Vec::new();
223 let mut step_num = 1;
224
225 plan.push(build_source_step(select, features, step_num));
226 step_num += 1;
227
228 append_filter_and_join_steps(select, features, &mut plan, &mut step_num);
229 append_aggregation_steps(features, &mut plan, &mut step_num);
230 let implicit_limit = parsed.match_clause.is_none() && parsed.compound.is_none();
232 append_pagination_step(select, &mut plan, step_num, implicit_limit);
233
234 plan
235}
236
237fn build_source_step(
238 select: &velesdb_core::velesql::SelectStatement,
239 features: &ExplainFeatures,
240 step_num: usize,
241) -> ExplainStep {
242 if features.has_vector_search {
243 ExplainStep {
244 step: step_num,
245 operation: "VectorSearch".to_string(),
246 description: "ANN search using HNSW index with NEAR clause".to_string(),
247 estimated_rows: select.limit.map(|l| l as usize),
248 estimation_method: None,
249 }
250 } else {
251 ExplainStep {
252 step: step_num,
253 operation: "FullScan".to_string(),
254 description: format!("Scan collection '{}'", select.from),
255 estimated_rows: None,
256 estimation_method: None,
257 }
258 }
259}
260
261fn append_filter_and_join_steps(
262 select: &velesdb_core::velesql::SelectStatement,
263 features: &ExplainFeatures,
264 plan: &mut Vec<ExplainStep>,
265 step_num: &mut usize,
266) {
267 if features.has_filter {
268 plan.push(ExplainStep {
269 step: *step_num,
270 operation: "Filter".to_string(),
271 description: "Apply WHERE clause predicates".to_string(),
272 estimated_rows: None,
273 estimation_method: None,
274 });
275 *step_num += 1;
276 }
277
278 for join in &select.joins {
279 plan.push(ExplainStep {
280 step: *step_num,
281 operation: format!("{:?}Join", join.join_type),
282 description: format!("Join with '{}'", join.table),
283 estimated_rows: None,
284 estimation_method: None,
285 });
286 *step_num += 1;
287 }
288}
289
290fn append_aggregation_steps(
291 features: &ExplainFeatures,
292 plan: &mut Vec<ExplainStep>,
293 step_num: &mut usize,
294) {
295 if features.has_group_by {
296 plan.push(ExplainStep {
297 step: *step_num,
298 operation: "GroupBy".to_string(),
299 description: "Group rows by specified columns".to_string(),
300 estimated_rows: None,
301 estimation_method: None,
302 });
303 *step_num += 1;
304 }
305
306 if features.has_aggregation {
307 plan.push(ExplainStep {
308 step: *step_num,
309 operation: "Aggregate".to_string(),
310 description: "Compute aggregate functions (COUNT, SUM, etc.)".to_string(),
311 estimated_rows: None,
312 estimation_method: None,
313 });
314 *step_num += 1;
315 }
316
317 if features.has_order_by {
318 plan.push(ExplainStep {
319 step: *step_num,
320 operation: "Sort".to_string(),
321 description: "Sort results by ORDER BY clause".to_string(),
322 estimated_rows: None,
323 estimation_method: None,
324 });
325 *step_num += 1;
326 }
327}
328
329fn append_pagination_step(
336 select: &velesdb_core::velesql::SelectStatement,
337 plan: &mut Vec<ExplainStep>,
338 step_num: usize,
339 implicit_limit: bool,
340) {
341 let Some((limit, is_default)) = resolve_pagination_limit(select, implicit_limit) else {
342 return;
343 };
344 let marker = if is_default { " (default)" } else { "" };
345 let estimated_rows = (select.limit.is_some() || is_default).then_some(limit as usize);
346 plan.push(ExplainStep {
347 step: step_num,
348 operation: "Limit".to_string(),
349 description: format!(
350 "Apply LIMIT {limit}{marker} OFFSET {}",
351 select.offset.unwrap_or(0)
352 ),
353 estimated_rows,
354 estimation_method: None,
355 });
356}
357
358fn resolve_pagination_limit(
363 select: &velesdb_core::velesql::SelectStatement,
364 implicit_limit: bool,
365) -> Option<(u64, bool)> {
366 match select.limit {
367 Some(limit) => Some((limit, false)),
368 None if implicit_limit => Some((DEFAULT_SELECT_LIMIT, true)),
369 None if select.offset.is_some() => Some((0, false)),
371 None => None,
372 }
373}
374
375fn estimate_cost(has_vector_search: bool) -> ExplainCost {
377 ExplainCost {
378 uses_index: has_vector_search,
379 index_name: if has_vector_search {
380 Some("HNSW".to_string())
381 } else {
382 None
383 },
384 selectivity: if has_vector_search { 0.01 } else { 1.0 },
385 complexity: if has_vector_search {
386 "O(log n)"
387 } else {
388 "O(n)"
389 }
390 .to_string(),
391 }
392}
393
394pub(super) fn condition_has_vector_search(cond: &Condition) -> bool {
396 match cond {
397 Condition::VectorSearch(_)
398 | Condition::VectorFusedSearch { .. }
399 | Condition::SparseVectorSearch(_)
400 | Condition::Similarity(_) => true,
401 Condition::And(left, right) | Condition::Or(left, right) => {
402 condition_has_vector_search(left) || condition_has_vector_search(right)
403 }
404 Condition::Group(inner) | Condition::Not(inner) => condition_has_vector_search(inner),
405 _ => false,
406 }
407}
408
409fn extract_filter_plan(
415 node: &velesdb_core::velesql::PlanNode,
416) -> Option<&velesdb_core::velesql::FilterPlan> {
417 match node {
418 velesdb_core::velesql::PlanNode::Filter(fp) => Some(fp),
419 velesdb_core::velesql::PlanNode::Sequence(nodes) => {
420 nodes.iter().find_map(extract_filter_plan)
421 }
422 _ => None,
423 }
424}
425
426#[allow(clippy::cast_possible_truncation)]
431fn merge_core_estimation(plan: &mut [ExplainStep], core_plan: &velesdb_core::velesql::QueryPlan) {
432 if let Some(fp) = extract_filter_plan(&core_plan.root) {
433 for step in plan.iter_mut() {
434 if step.operation == "Filter" {
435 step.estimated_rows = fp.estimated_rows.map(|r| r as usize);
436 step.estimation_method = fp.estimation_method.clone();
437 }
438 }
439 }
440}