Skip to main content

velesdb_core/velesql/explain/
step.rs

1//! Flattened, structured EXPLAIN step emission.
2//!
3//! [`QueryPlan::to_plan_steps`] walks the same [`PlanNode`] tree that
4//! [`QueryPlan::to_tree`](super::formatter) renders, producing the canonical
5//! structured step list consumed by the REST `/query/explain` endpoint. Both
6//! the text tree and this step list derive from one source of truth — the plan
7//! tree — so the server no longer reconstructs steps from the raw AST.
8
9use super::types::{
10    FilterPlan, JoinPlanNode, LimitPlan, PlanNode, PlanStep, PlanStepKind, QueryPlan,
11};
12
13use PlanStepKind as Kind;
14
15const VECTOR_DESC: &str = "ANN search using HNSW index with NEAR clause";
16const FILTER_DESC: &str = "Apply WHERE clause predicates";
17const GROUP_DESC: &str = "Group rows by specified columns";
18const AGGREGATE_DESC: &str = "Compute aggregate functions (COUNT, SUM, etc.)";
19const SORT_DESC: &str = "Sort results by ORDER BY clause";
20
21impl QueryPlan {
22    /// Flattens the plan tree into ordered, structured EXPLAIN steps.
23    ///
24    /// When the plan has a `LIMIT`, a standalone `OFFSET` node is folded into
25    /// that `LIMIT` step's description (`... OFFSET n`), matching the historical
26    /// flat step list. A query with `OFFSET` but no `LIMIT` (compound/MATCH)
27    /// instead surfaces a dedicated `Offset` step.
28    #[must_use]
29    pub fn to_plan_steps(&self) -> Vec<PlanStep> {
30        let mut flat = Vec::new();
31        Self::flatten_nodes(&self.root, &mut flat);
32        let offset = pagination_offset(&flat);
33        let has_limit = flat.iter().any(|n| matches!(n, PlanNode::Limit(_)));
34
35        let mut steps = Vec::new();
36        for node in flat {
37            if let Some(step) = step_for_node(node, steps.len() + 1, offset, has_limit) {
38                steps.push(step);
39            }
40        }
41        steps
42    }
43
44    /// Collects nodes in pipeline order, expanding nested `Sequence` nodes.
45    fn flatten_nodes<'a>(node: &'a PlanNode, out: &mut Vec<&'a PlanNode>) {
46        match node {
47            PlanNode::Sequence(children) => {
48                for child in children {
49                    Self::flatten_nodes(child, out);
50                }
51            }
52            other => out.push(other),
53        }
54    }
55}
56
57impl PlanStep {
58    /// Maps the step kind to the exact REST `operation` wire string.
59    ///
60    /// The vocabulary is preserved verbatim (e.g. `TableScan` → `"FullScan"`,
61    /// `Join` → `"{Type}Join"`) so the `/query/explain` contract is additive.
62    #[must_use]
63    pub fn rest_operation(&self) -> String {
64        match self.operation {
65            Kind::VectorSearch => "VectorSearch".to_string(),
66            Kind::TableScan => "FullScan".to_string(),
67            Kind::IndexLookup => "IndexLookup".to_string(),
68            Kind::Filter => "Filter".to_string(),
69            Kind::Join => format!("{}Join", self.join_type.as_deref().unwrap_or_default()),
70            Kind::GroupBy => "GroupBy".to_string(),
71            Kind::Aggregate => "Aggregate".to_string(),
72            Kind::Sort => "Sort".to_string(),
73            Kind::Limit => "Limit".to_string(),
74            Kind::Offset => "Offset".to_string(),
75            Kind::MatchTraversal => "MatchTraversal".to_string(),
76        }
77    }
78}
79
80/// Returns the OFFSET count to fold into the Limit step (0 when absent).
81fn pagination_offset(flat: &[&PlanNode]) -> u64 {
82    flat.iter()
83        .find_map(|n| match n {
84            PlanNode::Offset(o) => Some(o.count),
85            _ => None,
86        })
87        .unwrap_or(0)
88}
89
90/// Builds the structured step for a single leaf node.
91///
92/// Returns `None` for `Sequence` (flattened away) and for a standalone `Offset`
93/// when the plan also has a `LIMIT` (the offset is folded into the Limit step).
94/// An `Offset` with no `LIMIT` becomes its own `Offset` step.
95fn step_for_node(node: &PlanNode, step: usize, offset: u64, has_limit: bool) -> Option<PlanStep> {
96    let built = match node {
97        PlanNode::Sequence(_) => return None,
98        PlanNode::Offset(_) if has_limit => return None, // folded into the Limit step
99        PlanNode::Offset(o) => plain(
100            step,
101            Kind::Offset,
102            format!("Skip {} rows (OFFSET)", o.count),
103        ),
104        PlanNode::VectorSearch(_) => plain(step, Kind::VectorSearch, VECTOR_DESC.to_string()),
105        PlanNode::TableScan(ts) => plain(
106            step,
107            Kind::TableScan,
108            format!("Scan collection '{}'", ts.collection),
109        ),
110        PlanNode::IndexLookup(il) => plain(
111            step,
112            Kind::IndexLookup,
113            format!(
114                "Property index lookup {}.{} = {}",
115                il.label, il.property, il.value
116            ),
117        ),
118        PlanNode::GroupBy(_) => plain(step, Kind::GroupBy, GROUP_DESC.to_string()),
119        PlanNode::Aggregate(_) => plain(step, Kind::Aggregate, AGGREGATE_DESC.to_string()),
120        PlanNode::Sort(_) => plain(step, Kind::Sort, SORT_DESC.to_string()),
121        PlanNode::MatchTraversal(mt) => plain(
122            step,
123            Kind::MatchTraversal,
124            format!("Graph traversal: {}", mt.strategy),
125        ),
126        PlanNode::Filter(f) => filter_step(step, f),
127        PlanNode::Join(j) => join_step(step, j),
128        PlanNode::Limit(l) => limit_step(step, l, offset),
129    };
130    Some(built)
131}
132
133/// Builds a step with no join type, estimate, or estimation method.
134fn plain(step: usize, operation: PlanStepKind, description: String) -> PlanStep {
135    PlanStep {
136        step,
137        operation,
138        join_type: None,
139        description,
140        estimated_rows: None,
141        estimation_method: None,
142    }
143}
144
145/// Builds a `Filter` step, carrying the core plan's native row estimate.
146fn filter_step(step: usize, filter: &FilterPlan) -> PlanStep {
147    PlanStep {
148        step,
149        operation: Kind::Filter,
150        join_type: None,
151        description: FILTER_DESC.to_string(),
152        estimated_rows: filter.estimated_rows,
153        estimation_method: filter.estimation_method.clone(),
154    }
155}
156
157/// Builds a `Join` step, carrying the join-type label for the wire string.
158fn join_step(step: usize, join: &JoinPlanNode) -> PlanStep {
159    PlanStep {
160        step,
161        operation: Kind::Join,
162        join_type: Some(join.join_type.clone()),
163        description: format!("Join with '{}'", join.table),
164        estimated_rows: None,
165        estimation_method: None,
166    }
167}
168
169/// Builds a `Limit` step, folding any `OFFSET` into its description.
170fn limit_step(step: usize, limit: &LimitPlan, offset: u64) -> PlanStep {
171    PlanStep {
172        step,
173        operation: Kind::Limit,
174        join_type: None,
175        description: limit_description(limit, offset),
176        estimated_rows: Some(limit.count),
177        estimation_method: None,
178    }
179}
180
181/// Formats the LIMIT step description, folding OFFSET in (matches the prior
182/// server template `Apply LIMIT N (default) OFFSET M`).
183fn limit_description(limit: &LimitPlan, offset: u64) -> String {
184    let marker = if limit.is_default { " (default)" } else { "" };
185    format!("Apply LIMIT {}{marker} OFFSET {offset}", limit.count)
186}