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, 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/// 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/// Build an EXPLAIN-only response (no execution).
58fn 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
100/// Build an EXPLAIN ANALYZE response (with execution and actual stats).
101fn 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 metadata into server plan (graceful: already have output).
122    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
143/// Runs the core `explain_analyze_query` call, mapping core errors to the
144/// matching HTTP responses. Returns `Ok(output)` on success or `Err(response)`
145/// with the error already rendered.
146fn 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
166/// Splits the optional `actual_stats` + `node_stats` of an EXPLAIN ANALYZE
167/// output into the three response-side options consumed by
168/// [`ExplainResponse`].
169fn 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
191/// Detect query features from a SELECT statement for EXPLAIN output.
192fn 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
216/// Build the execution plan steps for an EXPLAIN response.
217fn 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    // MATCH and compound queries have no implicit default LIMIT.
231    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
329/// Appends the LIMIT/OFFSET step.
330///
331/// Plain SELECT statements (`implicit_limit == true`) without an explicit
332/// LIMIT surface the engine default ([`DEFAULT_SELECT_LIMIT`]) so the plan
333/// matches what execution actually returns. MATCH and compound queries
334/// (`implicit_limit == false`) have no implicit limit.
335fn 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
358/// Resolves the effective LIMIT for the pagination step.
359///
360/// Returns `(limit, is_default)`, or `None` when no step should be emitted
361/// (no LIMIT, no OFFSET, and no implicit default — MATCH/compound queries).
362fn 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        // OFFSET without LIMIT on a no-default query keeps the LIMIT 0 display.
370        None if select.offset.is_some() => Some((0, false)),
371        None => None,
372    }
373}
374
375/// Estimate execution cost based on query features.
376fn 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
394/// Check if a condition contains vector search.
395pub(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
409// ---------------------------------------------------------------------------
410// Core plan merge helpers (Task 2.2)
411// ---------------------------------------------------------------------------
412
413/// Recursively extracts the first `FilterPlan` from a core `PlanNode` tree.
414fn 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/// Merges core `FilterPlan` estimation data into server `ExplainStep` entries.
427///
428/// Copies `estimated_rows` and `estimation_method` from the core plan's
429/// `FilterPlan` into every server step whose `operation` is `"Filter"`.
430#[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}