Skip to main content

icydb_core/db/query/explain/
render.rs

1//! Module: query::explain::render
2//! Responsibility: text/json rendering for explain execution descriptors.
3//! Does not own: planner or executor decision derivation.
4//! Boundary: consumes explain projection types and emits deterministic render output.
5
6use crate::{
7    db::query::explain::{
8        ExplainExecutionNodeDescriptor,
9        access_projection::{access_strategy_label, write_access_json},
10        execution::{execution_mode_label, ordering_source_label},
11        writer::JsonWriter,
12    },
13    value::Value,
14};
15use std::{collections::BTreeMap, fmt::Write};
16
17impl ExplainExecutionNodeDescriptor {
18    /// Render this execution subtree as a compact text tree.
19    #[must_use]
20    pub fn render_text_tree(&self) -> String {
21        let mut lines = Vec::new();
22        let mut node_id_counter = 0_u64;
23        self.render_text_tree_into(0, &mut node_id_counter, &mut lines);
24        lines.join("\n")
25    }
26
27    /// Render this execution subtree as canonical JSON.
28    #[must_use]
29    pub fn render_json_canonical(&self) -> String {
30        let mut out = String::new();
31        let mut node_id_counter = 0_u64;
32        write_execution_node_json(self, &mut node_id_counter, &mut out);
33        out
34    }
35
36    /// Render this execution subtree as a verbose text tree with properties.
37    #[must_use]
38    pub fn render_text_tree_verbose(&self) -> String {
39        let mut lines = Vec::new();
40        let mut node_id_counter = 0_u64;
41        self.render_text_tree_verbose_into(0, &mut node_id_counter, &mut lines);
42        lines.join("\n")
43    }
44
45    fn render_text_tree_into(
46        &self,
47        depth: usize,
48        node_id_counter: &mut u64,
49        lines: &mut Vec<String>,
50    ) {
51        let node_id = next_node_id(node_id_counter);
52        let mut line = format!(
53            "{}{} execution_mode={}",
54            "  ".repeat(depth),
55            self.node_type.as_str(),
56            execution_mode_label(self.execution_mode)
57        );
58        let _ = write!(line, " node_id={node_id}");
59        let _ = write!(
60            line,
61            " execution_mode_detail={}",
62            execution_mode_detail_label(self.execution_mode)
63        );
64        let _ = write!(
65            line,
66            " predicate_pushdown_mode={}",
67            predicate_pushdown_mode(self)
68        );
69        if let Some(fast_path_selected) = fast_path_selected(self) {
70            let _ = write!(line, " fast_path_selected={fast_path_selected}");
71        }
72        if let Some(fast_path_reason) = fast_path_reason(self) {
73            let _ = write!(line, " fast_path_reason={fast_path_reason}");
74        }
75
76        if let Some(access_strategy) = self.access_strategy.as_ref() {
77            let _ = write!(line, " access={}", access_strategy_label(access_strategy));
78        }
79        if let Some(predicate_pushdown) = self.predicate_pushdown.as_ref() {
80            let _ = write!(line, " predicate_pushdown={predicate_pushdown}");
81        }
82        if let Some(residual_predicate) = self.residual_predicate.as_ref() {
83            let _ = write!(line, " residual_predicate={residual_predicate:?}");
84        }
85        if let Some(projection) = self.projection.as_ref() {
86            let _ = write!(line, " projection={projection}");
87        }
88        if let Some(ordering_source) = self.ordering_source {
89            let _ = write!(
90                line,
91                " ordering_source={}",
92                ordering_source_label(ordering_source)
93            );
94        }
95        if let Some(limit) = self.limit {
96            let _ = write!(line, " limit={limit}");
97        }
98        if let Some(cursor) = self.cursor {
99            let _ = write!(line, " cursor={cursor}");
100        }
101        if let Some(covering_scan) = self.covering_scan {
102            let _ = write!(line, " covering_scan={covering_scan}");
103        }
104        if let Some(rows_expected) = self.rows_expected {
105            let _ = write!(line, " rows_expected={rows_expected}");
106        }
107        if !self.node_properties.is_empty() {
108            let _ = write!(
109                line,
110                " node_properties={}",
111                render_node_properties(&self.node_properties)
112            );
113        }
114
115        lines.push(line);
116
117        for child in &self.children {
118            child.render_text_tree_into(depth.saturating_add(1), node_id_counter, lines);
119        }
120    }
121
122    fn render_text_tree_verbose_into(
123        &self,
124        depth: usize,
125        node_id_counter: &mut u64,
126        lines: &mut Vec<String>,
127    ) {
128        let node_id = next_node_id(node_id_counter);
129        // Emit the node heading line first so child metadata stays visually scoped.
130        let node_indent = "  ".repeat(depth);
131        let field_indent = "  ".repeat(depth.saturating_add(1));
132        lines.push(format!(
133            "{}{} execution_mode={}",
134            node_indent,
135            self.node_type.as_str(),
136            execution_mode_label(self.execution_mode)
137        ));
138        lines.push(format!("{field_indent}node_id={node_id}"));
139        lines.push(format!(
140            "{}execution_mode_detail={}",
141            field_indent,
142            execution_mode_detail_label(self.execution_mode)
143        ));
144        lines.push(format!(
145            "{}predicate_pushdown_mode={}",
146            field_indent,
147            predicate_pushdown_mode(self)
148        ));
149        if let Some(fast_path_selected) = fast_path_selected(self) {
150            lines.push(format!(
151                "{field_indent}fast_path_selected={fast_path_selected}"
152            ));
153        }
154        if let Some(fast_path_reason) = fast_path_reason(self) {
155            lines.push(format!("{field_indent}fast_path_reason={fast_path_reason}"));
156        }
157
158        // Emit all optional node-local fields in a deterministic order.
159        if let Some(access_strategy) = self.access_strategy.as_ref() {
160            lines.push(format!("{field_indent}access_strategy={access_strategy:?}"));
161        }
162        if let Some(predicate_pushdown) = self.predicate_pushdown.as_ref() {
163            lines.push(format!(
164                "{field_indent}predicate_pushdown={predicate_pushdown}"
165            ));
166        }
167        if let Some(residual_predicate) = self.residual_predicate.as_ref() {
168            lines.push(format!(
169                "{field_indent}residual_predicate={residual_predicate:?}"
170            ));
171        }
172        if let Some(projection) = self.projection.as_ref() {
173            lines.push(format!("{field_indent}projection={projection}"));
174        }
175        if let Some(ordering_source) = self.ordering_source {
176            lines.push(format!(
177                "{}ordering_source={}",
178                field_indent,
179                ordering_source_label(ordering_source)
180            ));
181        }
182        if let Some(limit) = self.limit {
183            lines.push(format!("{field_indent}limit={limit}"));
184        }
185        if let Some(cursor) = self.cursor {
186            lines.push(format!("{field_indent}cursor={cursor}"));
187        }
188        if let Some(covering_scan) = self.covering_scan {
189            lines.push(format!("{field_indent}covering_scan={covering_scan}"));
190        }
191        if let Some(rows_expected) = self.rows_expected {
192            lines.push(format!("{field_indent}rows_expected={rows_expected}"));
193        }
194        if !self.node_properties.is_empty() {
195            lines.push(format!(
196                "{}node_properties={}",
197                field_indent,
198                render_node_properties(&self.node_properties)
199            ));
200        }
201
202        // Recurse in execution order to preserve stable tree topology.
203        for child in &self.children {
204            child.render_text_tree_verbose_into(depth.saturating_add(1), node_id_counter, lines);
205        }
206    }
207}
208
209fn render_node_properties(node_properties: &BTreeMap<String, Value>) -> String {
210    let mut rendered = String::new();
211    let mut first = true;
212    for (key, value) in node_properties {
213        if first {
214            first = false;
215        } else {
216            rendered.push(',');
217        }
218        let _ = write!(rendered, "{key}={value:?}");
219    }
220    rendered
221}
222
223fn write_execution_node_json(
224    node: &ExplainExecutionNodeDescriptor,
225    node_id_counter: &mut u64,
226    out: &mut String,
227) {
228    let node_id = next_node_id(node_id_counter);
229    let mut object = JsonWriter::begin_object(out);
230
231    object.field_u64("node_id", node_id);
232    object.field_str("node_type", node.node_type.as_str());
233    object.field_str("execution_mode", execution_mode_label(node.execution_mode));
234    object.field_str(
235        "execution_mode_detail",
236        execution_mode_detail_label(node.execution_mode),
237    );
238    object.field_with("access_strategy", |out| {
239        match node.access_strategy.as_ref() {
240            Some(access) => write_access_json(access, out),
241            None => out.push_str("null"),
242        }
243    });
244    object.field_str("predicate_pushdown_mode", predicate_pushdown_mode(node));
245    match node.predicate_pushdown.as_deref() {
246        Some(predicate_pushdown) => object.field_str("predicate_pushdown", predicate_pushdown),
247        None => object.field_null("predicate_pushdown"),
248    }
249    match fast_path_selected(node) {
250        Some(selected) => object.field_bool("fast_path_selected", selected),
251        None => object.field_null("fast_path_selected"),
252    }
253    match fast_path_reason(node) {
254        Some(reason) => object.field_str("fast_path_reason", reason),
255        None => object.field_null("fast_path_reason"),
256    }
257    match node.residual_predicate.as_ref() {
258        Some(residual_predicate) => {
259            object.field_value_debug("residual_predicate", residual_predicate);
260        }
261        None => object.field_null("residual_predicate"),
262    }
263    match node.projection.as_deref() {
264        Some(projection) => object.field_str("projection", projection),
265        None => object.field_null("projection"),
266    }
267    match node.ordering_source {
268        Some(ordering_source) => {
269            object.field_str("ordering_source", ordering_source_label(ordering_source));
270        }
271        None => object.field_null("ordering_source"),
272    }
273    match node.limit {
274        Some(limit) => object.field_u64("limit", u64::from(limit)),
275        None => object.field_null("limit"),
276    }
277    match node.cursor {
278        Some(cursor) => object.field_bool("cursor", cursor),
279        None => object.field_null("cursor"),
280    }
281    match node.covering_scan {
282        Some(covering_scan) => object.field_bool("covering_scan", covering_scan),
283        None => object.field_null("covering_scan"),
284    }
285    match node.rows_expected {
286        Some(rows_expected) => object.field_u64("rows_expected", rows_expected),
287        None => object.field_null("rows_expected"),
288    }
289    object.field_with("children", |out| {
290        out.push('[');
291        for (index, child) in node.children.iter().enumerate() {
292            if index > 0 {
293                out.push(',');
294            }
295            write_execution_node_json(child, node_id_counter, out);
296        }
297        out.push(']');
298    });
299    object.field_debug_map("node_properties", &node.node_properties);
300
301    object.finish();
302}
303
304const fn next_node_id(node_id_counter: &mut u64) -> u64 {
305    let node_id = *node_id_counter;
306    *node_id_counter = node_id_counter.saturating_add(1);
307    node_id
308}
309
310const fn execution_mode_detail_label(
311    mode: crate::db::query::explain::ExplainExecutionMode,
312) -> &'static str {
313    match mode {
314        crate::db::query::explain::ExplainExecutionMode::Streaming => "streaming",
315        crate::db::query::explain::ExplainExecutionMode::Materialized => "materialized",
316    }
317}
318
319fn predicate_pushdown_mode(node: &ExplainExecutionNodeDescriptor) -> &'static str {
320    match node.predicate_pushdown.as_deref() {
321        None => "none",
322        Some("strict_all_or_none") => "full",
323        Some(_) => {
324            if node.residual_predicate.is_some() {
325                "partial"
326            } else {
327                "full"
328            }
329        }
330    }
331}
332
333fn fast_path_selected(node: &ExplainExecutionNodeDescriptor) -> Option<bool> {
334    let selected = node.node_properties.get("fast_path_selected")?;
335    match selected {
336        Value::Text(path) => Some(path.as_str() != "none"),
337        _ => None,
338    }
339}
340
341fn fast_path_reason(node: &ExplainExecutionNodeDescriptor) -> Option<&str> {
342    let reason = node.node_properties.get("fast_path_selected_reason")?;
343    match reason {
344        Value::Text(reason) => Some(reason.as_str()),
345        _ => None,
346    }
347}