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:
claude@clouddev1
2026-05-19 12:38:02 +00:00
parent c1fcf28e04
commit d17addddd7
11 changed files with 836 additions and 67 deletions
+128 -1
View File
@@ -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 {