Skip to main content

velesdb_core/velesql/explain/
formatter.rs

1//! Query plan rendering and formatting for EXPLAIN output.
2//!
3//! Extracted from `explain.rs` for maintainability (04-06 module splitting).
4//! Handles tree rendering, JSON serialization, and Display formatting.
5
6use std::fmt::{self, Write as _};
7
8use super::{
9    FilterPlan, FilterStrategy, FusionInfo, IndexType, MatchTraversalPlan, PlanNode, QueryPlan,
10};
11
12impl QueryPlan {
13    /// Renders the plan as a tree string.
14    #[must_use]
15    pub fn to_tree(&self) -> String {
16        let mut output = String::from("Query Plan:\n");
17        Self::render_node(&self.root, &mut output, "", true);
18
19        Self::render_with_options(&self.with_options, &mut output);
20        Self::render_let_bindings(&self.let_bindings, &mut output);
21        Self::render_fusion_info(self.fusion_info.as_ref(), &mut output);
22
23        let _ = write!(
24            output,
25            "\nEstimated cost: {:.3}ms\n",
26            self.estimated_cost_ms
27        );
28
29        if let Some(ref idx) = self.index_used {
30            let _ = writeln!(output, "Index used: {}", idx.as_str());
31        }
32
33        if self.filter_strategy != FilterStrategy::None {
34            let _ = writeln!(output, "Filter strategy: {}", self.filter_strategy.as_str());
35        }
36
37        if let Some(hit) = self.cache_hit {
38            let _ = writeln!(output, "Cache hit: {hit}");
39        }
40        if let Some(count) = self.plan_reuse_count {
41            let _ = writeln!(output, "Plan reuse count: {count}");
42        }
43
44        output
45    }
46
47    /// Renders WITH clause options into the tree output.
48    fn render_with_options(options: &[(String, String)], output: &mut String) {
49        if options.is_empty() {
50            return;
51        }
52        let _ = writeln!(output, "\nWITH options:");
53        for (key, value) in options {
54            let _ = writeln!(output, "  {key} = {value}");
55        }
56    }
57
58    /// Renders LET bindings into the tree output.
59    fn render_let_bindings(bindings: &[String], output: &mut String) {
60        if bindings.is_empty() {
61            return;
62        }
63        let _ = writeln!(output, "\nLET bindings:");
64        for binding in bindings {
65            let _ = writeln!(output, "  {binding}");
66        }
67    }
68
69    /// Renders FUSION info into the tree output.
70    fn render_fusion_info(info: Option<&FusionInfo>, output: &mut String) {
71        let Some(fi) = info else { return };
72        let _ = writeln!(output, "\nFUSION:");
73        let _ = writeln!(output, "  Strategy: {}", fi.strategy);
74        if let Some(k) = fi.k {
75            let _ = writeln!(output, "  k: {k}");
76        }
77        if let Some(ref w) = fi.weights {
78            let _ = writeln!(output, "  Weights: {w}");
79        }
80    }
81
82    pub(crate) fn render_node(node: &PlanNode, output: &mut String, prefix: &str, is_last: bool) {
83        let connector = if is_last { "└─ " } else { "├─ " };
84        let child_prefix = format!("{}{}", prefix, if is_last { "   " } else { "│  " });
85
86        match node {
87            PlanNode::VectorSearch(vs) => {
88                let _ = writeln!(output, "{prefix}{connector}VectorSearch");
89                let _ = writeln!(output, "{child_prefix}├─ Collection: {}", vs.collection);
90                let _ = writeln!(output, "{child_prefix}├─ ef_search: {}", vs.ef_search);
91                let _ = writeln!(output, "{child_prefix}└─ Candidates: {}", vs.candidates);
92            }
93            PlanNode::Filter(f) => {
94                Self::render_filter_node(f, output, prefix, connector, &child_prefix);
95            }
96            PlanNode::Limit(l) => {
97                let suffix = if l.is_default { " (default)" } else { "" };
98                let _ = writeln!(output, "{prefix}{connector}Limit: {}{suffix}", l.count);
99            }
100            PlanNode::Offset(o) => {
101                let _ = writeln!(output, "{prefix}{connector}Offset: {}", o.count);
102            }
103            PlanNode::TableScan(ts) => {
104                let _ = writeln!(output, "{prefix}{connector}TableScan: {}", ts.collection);
105            }
106            PlanNode::IndexLookup(il) => {
107                let _ = writeln!(
108                    output,
109                    "{prefix}{connector}IndexLookup({}.{})",
110                    il.label, il.property
111                );
112                let _ = writeln!(output, "{child_prefix}└─ Value: {}", il.value);
113            }
114            PlanNode::Sequence(nodes) => {
115                for (i, child) in nodes.iter().enumerate() {
116                    Self::render_node(child, output, prefix, i == nodes.len() - 1);
117                }
118            }
119            PlanNode::MatchTraversal(mt) => {
120                Self::render_match_traversal_node(mt, output, prefix, connector, &child_prefix);
121            }
122            PlanNode::Join(_)
123            | PlanNode::GroupBy(_)
124            | PlanNode::Aggregate(_)
125            | PlanNode::Sort(_) => {
126                Self::render_post_filter_node(node, output, (prefix, &child_prefix, connector));
127            }
128        }
129    }
130
131    /// Renders a post-filter node (Join/GroupBy/Aggregate/Sort) as a label plus
132    /// one child line. Split out of [`Self::render_node`] to keep it small.
133    fn render_post_filter_node(node: &PlanNode, output: &mut String, frame: (&str, &str, &str)) {
134        match node {
135            PlanNode::Join(j) => {
136                Self::render_leaf_with_child(
137                    output,
138                    frame,
139                    &format!("{}Join", j.join_type),
140                    ("Table", &j.table),
141                );
142            }
143            PlanNode::GroupBy(g) => Self::render_leaf_with_child(
144                output,
145                frame,
146                "GroupBy",
147                ("Columns", &format!("[{}]", g.columns.join(", "))),
148            ),
149            PlanNode::Aggregate(a) => Self::render_leaf_with_child(
150                output,
151                frame,
152                "Aggregate",
153                ("Functions", &format!("[{}]", a.functions.join(", "))),
154            ),
155            PlanNode::Sort(s) => {
156                Self::render_leaf_with_child(output, frame, "Sort", ("Keys", &s.keys.join(", ")));
157            }
158            _ => {}
159        }
160    }
161
162    /// Renders a simple "label + single child line" node into the tree output.
163    ///
164    /// Shared by the Join/GroupBy/Aggregate/Sort arms. `frame` carries the
165    /// `(prefix, child_prefix, connector)` layout strings; `child` is the
166    /// `(key, value)` of the single child line.
167    fn render_leaf_with_child(
168        output: &mut String,
169        frame: (&str, &str, &str),
170        label: &str,
171        child: (&str, &str),
172    ) {
173        let (prefix, child_prefix, connector) = frame;
174        let (key, value) = child;
175        let _ = writeln!(output, "{prefix}{connector}{label}");
176        let _ = writeln!(output, "{child_prefix}└─ {key}: {value}");
177    }
178
179    /// Renders a `Filter` plan node into the tree output.
180    fn render_filter_node(
181        f: &FilterPlan,
182        output: &mut String,
183        prefix: &str,
184        connector: &str,
185        child_prefix: &str,
186    ) {
187        let _ = writeln!(output, "{prefix}{connector}Filter");
188        let _ = writeln!(output, "{child_prefix}├─ Conditions: {}", f.conditions);
189        // R7: estimated_rows and estimation_method are rendered when present.
190        if let Some(rows) = f.estimated_rows {
191            let _ = writeln!(output, "{child_prefix}├─ Estimated rows: {rows}");
192        }
193        if let Some(ref method) = f.estimation_method {
194            let _ = writeln!(output, "{child_prefix}├─ Estimation method: {method}");
195        }
196        let _ = writeln!(
197            output,
198            "{child_prefix}└─ Selectivity: {:.1}%",
199            f.selectivity * 100.0
200        );
201    }
202
203    /// Renders a `MatchTraversal` plan node into the tree output.
204    fn render_match_traversal_node(
205        mt: &MatchTraversalPlan,
206        output: &mut String,
207        prefix: &str,
208        connector: &str,
209        child_prefix: &str,
210    ) {
211        let _ = writeln!(output, "{prefix}{connector}MatchTraversal");
212        let _ = writeln!(output, "{child_prefix}├─ Strategy: {}", mt.strategy);
213        if !mt.start_labels.is_empty() {
214            let _ = writeln!(
215                output,
216                "{child_prefix}├─ Start Labels: [{}]",
217                mt.start_labels.join(", ")
218            );
219        }
220        let _ = writeln!(output, "{child_prefix}├─ Max Depth: {}", mt.max_depth);
221        let _ = writeln!(
222            output,
223            "{child_prefix}├─ Relationships: {}",
224            mt.relationship_count
225        );
226        if let Some(threshold) = mt.similarity_threshold {
227            let _ = writeln!(
228                output,
229                "{child_prefix}└─ Similarity Threshold: {:.2}",
230                threshold
231            );
232        } else {
233            let _ = writeln!(
234                output,
235                "{child_prefix}└─ Similarity: {}",
236                if mt.has_similarity { "yes" } else { "no" }
237            );
238        }
239    }
240
241    /// Renders the plan as JSON.
242    ///
243    /// # Errors
244    ///
245    /// Returns an error if serialization fails.
246    pub fn to_json(&self) -> Result<String, serde_json::Error> {
247        serde_json::to_string_pretty(self)
248    }
249}
250
251impl IndexType {
252    /// Returns the index type as a string.
253    #[must_use]
254    pub const fn as_str(&self) -> &'static str {
255        match self {
256            Self::Hnsw => "HNSW",
257            Self::Flat => "Flat",
258            Self::BinaryQuantization => "BinaryQuantization",
259            Self::Property => "PropertyIndex",
260        }
261    }
262}
263
264impl FilterStrategy {
265    /// Returns the filter strategy as a string.
266    #[must_use]
267    pub const fn as_str(&self) -> &'static str {
268        match self {
269            Self::None => "none",
270            Self::PreFilter => "pre-filtering (high selectivity)",
271            Self::PostFilter => "post-filtering (low selectivity)",
272        }
273    }
274}
275
276impl super::super::ast::CompareOp {
277    /// Returns the operator as a string.
278    #[must_use]
279    pub const fn as_str(&self) -> &'static str {
280        match self {
281            Self::Eq => "=",
282            Self::NotEq => "!=",
283            Self::Gt => ">",
284            Self::Gte => ">=",
285            Self::Lt => "<",
286            Self::Lte => "<=",
287        }
288    }
289}
290
291impl fmt::Display for QueryPlan {
292    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293        write!(f, "{}", self.to_tree())
294    }
295}
296
297/// Formats a `WithValue` for human-readable EXPLAIN display.
298pub(super) fn format_with_value(v: &super::super::ast::WithValue) -> String {
299    match v {
300        super::super::ast::WithValue::String(s) | super::super::ast::WithValue::Identifier(s) => {
301            s.clone()
302        }
303        super::super::ast::WithValue::Integer(i) => i.to_string(),
304        super::super::ast::WithValue::Float(f) => f.to_string(),
305        super::super::ast::WithValue::Boolean(b) => b.to_string(),
306    }
307}