explain: explain command end to end (ADR-0028 steps 2–3)
Add the `explain` prefix command — `explain show data`,
`explain update`, `explain delete` — from grammar through to a
rendered plan tree.
- Grammar: an `EXPLAIN` CommandNode whose shape is a Choice over
the three explainable query shapes, referenced (not
duplicated) through `Subgrammar`. `Command::Explain { query:
Box<Self> }`; `build_show_data` is extracted so the role-based
builders serve both standalone and explain-wrapped commands.
- Worker: SQL construction is split out of do_query_data /
do_update / do_delete into `build_*_sql`, so EXPLAIN QUERY
PLAN runs the exact same statement. `Request::ExplainPlan` /
`do_explain_plan` capture the plan; `QueryPlan` / `ExplainRow`
carry it back. EXPLAIN QUERY PLAN never executes, so
explaining update/delete changes nothing.
- Display SQL: the executed statement with `?N` parameters
inlined as standard-SQL literals via a quote-aware scan.
- Render: `render_explain_plan` draws the box-drawing plan tree
(plain output; ADR-0028 step 4 adds the styled tree).
- Catalog: `parse.usage.explain` and the `help.data.explain`
entry, so `explain` shows up in the in-app `help` listing.
1151 tests pass (+18); clippy clean.
This commit is contained in:
+128
-1
@@ -20,8 +20,12 @@
|
||||
//! respectively (display-only — underlying data is
|
||||
//! untouched).
|
||||
|
||||
use crate::db::{ColumnDescription, DataResult, TableDescription};
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::app::{OutputKind, OutputLine};
|
||||
use crate::db::{ColumnDescription, DataResult, ExplainRow, QueryPlan, TableDescription};
|
||||
use crate::dsl::Type;
|
||||
use crate::mode::Mode;
|
||||
|
||||
const NULL_DISPLAY: &str = "(null)";
|
||||
|
||||
@@ -148,6 +152,72 @@ pub fn render_structure(desc: &TableDescription) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Render a captured query plan as a box-drawing tree
|
||||
/// (ADR-0028 §3).
|
||||
///
|
||||
/// The standard-SQL display form of the explained statement is
|
||||
/// shown first; the plan tree follows, built from each row's
|
||||
/// `id` / `parent` links. Node text is the engine's `detail`
|
||||
/// string **verbatim** — nothing is reworded.
|
||||
///
|
||||
/// Unlike the `Vec<String>`-returning renderers above this
|
||||
/// returns ready-built `OutputLine`s, because a plan line
|
||||
/// carries per-span styling (ADR-0028 §5). Each line is
|
||||
/// stamped with `mode` for the submission-mode tag.
|
||||
#[must_use]
|
||||
pub fn render_explain_plan(plan: &QueryPlan, mode: Mode) -> Vec<OutputLine> {
|
||||
let mut out: Vec<OutputLine> = Vec::with_capacity(plan.rows.len() + 1);
|
||||
out.push(plain_plan_line(plan.display_sql.clone(), mode));
|
||||
// `emitted` guards against a malformed plan with a cyclic
|
||||
// or self-referential `parent` link — every node is drawn
|
||||
// at most once.
|
||||
let mut emitted: HashSet<i64> = HashSet::new();
|
||||
render_plan_subtree(&plan.rows, 0, "", &mut out, &mut emitted, mode);
|
||||
out
|
||||
}
|
||||
|
||||
/// Append the subtree rooted at `parent` (`0` = top level) to
|
||||
/// `out`, drawing box-drawing connectors. `prefix` is the
|
||||
/// indent accumulated from ancestor levels.
|
||||
fn render_plan_subtree(
|
||||
rows: &[ExplainRow],
|
||||
parent: i64,
|
||||
prefix: &str,
|
||||
out: &mut Vec<OutputLine>,
|
||||
emitted: &mut HashSet<i64>,
|
||||
mode: Mode,
|
||||
) {
|
||||
let children: Vec<&ExplainRow> =
|
||||
rows.iter().filter(|r| r.parent == parent).collect();
|
||||
let last_idx = children.len().saturating_sub(1);
|
||||
for (idx, row) in children.iter().enumerate() {
|
||||
if !emitted.insert(row.id) {
|
||||
continue;
|
||||
}
|
||||
let is_last = idx == last_idx;
|
||||
let connector = if is_last { "└─ " } else { "├─ " };
|
||||
out.push(plain_plan_line(
|
||||
format!("{prefix}{connector}{}", row.detail),
|
||||
mode,
|
||||
));
|
||||
let child_prefix =
|
||||
format!("{prefix}{}", if is_last { " " } else { "│ " });
|
||||
render_plan_subtree(rows, row.id, &child_prefix, out, emitted, mode);
|
||||
}
|
||||
}
|
||||
|
||||
/// A plain (unstyled) plan output line. ADR-0028 step 4 swaps
|
||||
/// the tree lines for span-styled ones; the display-SQL line
|
||||
/// stays plain.
|
||||
const fn plain_plan_line(text: String, mode: Mode) -> OutputLine {
|
||||
OutputLine {
|
||||
text,
|
||||
kind: OutputKind::System,
|
||||
mode_at_submission: mode,
|
||||
styled_runs: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Alignment {
|
||||
Left,
|
||||
@@ -647,6 +717,63 @@ mod tests {
|
||||
assert!(!out.contains("Indexes:"), "got:\n{out}");
|
||||
}
|
||||
|
||||
// --- render_explain_plan (ADR-0028 §3) --------------
|
||||
|
||||
#[test]
|
||||
fn render_explain_plan_puts_display_sql_first() {
|
||||
let plan = QueryPlan {
|
||||
display_sql: "SELECT \"id\" FROM \"T\"".to_string(),
|
||||
rows: vec![ExplainRow {
|
||||
id: 2,
|
||||
parent: 0,
|
||||
detail: "SCAN T".to_string(),
|
||||
}],
|
||||
};
|
||||
let lines = render_explain_plan(&plan, Mode::Simple);
|
||||
assert_eq!(lines[0].text, "SELECT \"id\" FROM \"T\"");
|
||||
assert!(lines[1].text.contains("SCAN T"), "got {:?}", lines[1].text);
|
||||
assert!(lines[1].text.contains('└'), "last node uses └─");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_explain_plan_nests_children_under_parents() {
|
||||
let plan = QueryPlan {
|
||||
display_sql: "SELECT 1".to_string(),
|
||||
rows: vec![
|
||||
ExplainRow { id: 1, parent: 0, detail: "root".to_string() },
|
||||
ExplainRow { id: 2, parent: 1, detail: "child-a".to_string() },
|
||||
ExplainRow { id: 3, parent: 1, detail: "child-b".to_string() },
|
||||
],
|
||||
};
|
||||
let lines = render_explain_plan(&plan, Mode::Simple);
|
||||
// display SQL + 3 plan nodes.
|
||||
assert_eq!(lines.len(), 4);
|
||||
assert!(lines[1].text.contains("root"));
|
||||
assert!(lines[2].text.contains("├─ child-a"), "got {:?}", lines[2].text);
|
||||
assert!(lines[3].text.contains("└─ child-b"), "got {:?}", lines[3].text);
|
||||
// The single root uses `└─`; its children are indented
|
||||
// by three spaces (no `│` spine, the root being last).
|
||||
assert!(lines[1].text.starts_with("└─ root"));
|
||||
assert!(lines[2].text.starts_with(" ├─ child-a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_explain_plan_survives_a_self_referential_node() {
|
||||
// A malformed plan node that is both a root (`parent ==
|
||||
// 0`) and its own parent (`id == 0`) must not loop.
|
||||
let plan = QueryPlan {
|
||||
display_sql: String::new(),
|
||||
rows: vec![ExplainRow {
|
||||
id: 0,
|
||||
parent: 0,
|
||||
detail: "self-rooted".to_string(),
|
||||
}],
|
||||
};
|
||||
let lines = render_explain_plan(&plan, Mode::Simple);
|
||||
// display-SQL line + the node, drawn exactly once.
|
||||
assert_eq!(lines.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_structure_falls_back_to_sqlite_type_when_user_type_missing() {
|
||||
let mut desc = TableDescription {
|
||||
|
||||
Reference in New Issue
Block a user