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
+64
View File
@@ -398,6 +398,10 @@ impl App {
self.handle_dsl_query_success(&command, &data);
Vec::new()
}
AppEvent::DslExplainSucceeded { command, plan } => {
self.handle_dsl_explain_success(&command, &plan);
Vec::new()
}
AppEvent::DslInsertSucceeded { command, result } => {
self.handle_dsl_insert_success(&command, &result);
Vec::new()
@@ -1201,6 +1205,20 @@ impl App {
}
}
fn handle_dsl_explain_success(
&mut self,
command: &Command,
plan: &crate::db::QueryPlan,
) {
self.note_ok_summary(command);
// ADR-0028 §3: the display SQL, then the plan tree.
// `render_explain_plan` returns ready-built `OutputLine`s
// so it can carry the per-span styling (ADR-0028 §5).
for line in crate::output_render::render_explain_plan(plan, self.mode) {
self.push_output(line);
}
}
fn handle_dsl_insert_success(&mut self, command: &Command, result: &InsertResult) {
self.note_ok_summary(command);
self.note_system(crate::t!("ok.rows_inserted", count = result.rows_affected));
@@ -1416,6 +1434,11 @@ impl App {
(Operation::Query, Some(name.as_str()), None)
}
C::Replay { .. } => (Operation::Replay, None, None),
// An `explain` failure (e.g. unknown table) is best
// described by the wrapped query it failed to plan.
C::Explain { query } => {
return self.build_translate_context(query, facts);
}
// App-lifecycle commands never reach this path —
// `dispatch_input` routes them through
// `dispatch_app_command` before the DSL execution
@@ -2419,6 +2442,47 @@ mod tests {
assert!(app.output.iter().any(|l| l.text.starts_with("[ok]")));
}
#[test]
fn explain_success_event_renders_display_sql_and_plan_tree() {
let mut app = App::new();
let cmd = Command::Explain {
query: Box::new(Command::ShowData {
name: "Customers".to_string(),
filter: None,
limit: None,
}),
};
let plan = crate::db::QueryPlan {
display_sql: "SELECT \"id\" FROM \"Customers\"".to_string(),
rows: vec![crate::db::ExplainRow {
id: 2,
parent: 0,
detail: "SCAN Customers".to_string(),
}],
};
app.update(AppEvent::DslExplainSucceeded {
command: cmd,
plan,
});
// `[ok] explain Customers` header.
assert!(
app.output.iter().any(|l| l.text.starts_with("[ok]")
&& l.text.contains("explain")),
"expected an [ok] explain header",
);
// The display SQL and the plan node both reach output.
assert!(
app.output
.iter()
.any(|l| l.text.contains("SELECT \"id\" FROM \"Customers\"")),
"expected the display SQL line",
);
assert!(
app.output.iter().any(|l| l.text.contains("SCAN Customers")),
"expected the plan-tree node",
);
}
#[test]
fn replay_command_dispatches_replay_action_not_execute_dsl() {
// Submitting `replay <path>` must NOT produce an