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
+170 -5
View File
@@ -310,6 +310,48 @@ const DELETE_NODES: &[Node] = &[
];
const DELETE_SHAPE: Node = Node::Seq(DELETE_NODES);
// =================================================================
// explain — `explain (show data … | update … | delete from …)`
// =================================================================
//
// ADR-0028 §1: `explain` is a top-level command whose shape is a
// `Choice` over the three explainable query commands. The inner
// query grammars are *referenced* through `Subgrammar`, not
// duplicated — so an explained command is parsed, completed,
// hinted and highlighted exactly as it is on its own.
//
// `Subgrammar` needs a `&'static Node`; `SHOW_DATA` /
// `UPDATE_SHAPE` / `DELETE_SHAPE` are `const` (and cannot be
// referenced as `&'static`). These three thin `static` wrappers
// over the existing `_NODES` slices give the references without
// any churn to the standalone command shapes. `explain show`
// references `EXPLAIN_SHOW_DATA` directly (not the `show`
// command's `data | table` choice) — `explain` covers `show
// data` only (ADR-0028 §1).
static EXPLAIN_SHOW_DATA: Node = Node::Seq(SHOW_DATA_NODES);
static EXPLAIN_UPDATE: Node = Node::Seq(UPDATE_NODES);
static EXPLAIN_DELETE: Node = Node::Seq(DELETE_NODES);
const EXPLAIN_SHOW_NODES: &[Node] = &[
Node::Word(Word::keyword("show")),
Node::Subgrammar(&EXPLAIN_SHOW_DATA),
];
const EXPLAIN_UPDATE_NODES: &[Node] = &[
Node::Word(Word::keyword("update")),
Node::Subgrammar(&EXPLAIN_UPDATE),
];
const EXPLAIN_DELETE_NODES: &[Node] = &[
Node::Word(Word::keyword("delete")),
Node::Subgrammar(&EXPLAIN_DELETE),
];
const EXPLAIN_CHOICES: &[Node] = &[
Node::Seq(EXPLAIN_SHOW_NODES),
Node::Seq(EXPLAIN_UPDATE_NODES),
Node::Seq(EXPLAIN_DELETE_NODES),
];
const EXPLAIN_SHAPE: Node = Node::Choice(EXPLAIN_CHOICES);
// =================================================================
// AST builders
// =================================================================
@@ -355,11 +397,7 @@ fn build_show(path: &MatchedPath) -> Result<Command, ValidationError> {
.nth(1);
let name = require_ident(path, "table_name")?;
match sub {
Some("data") => Ok(Command::ShowData {
name,
filter: build_show_filter(path)?,
limit: build_show_limit(path)?,
}),
Some("data") => build_show_data(path),
Some("table") => Ok(Command::ShowTable { name }),
_ => Err(ValidationError {
message_key: "parse.error_wrapper",
@@ -368,6 +406,18 @@ fn build_show(path: &MatchedPath) -> Result<Command, ValidationError> {
}
}
/// Build a `show data` command from a matched path. Role-based
/// (no positional `nth` lookups), so it serves both the
/// standalone `show data` entry word and the `explain show
/// data …` wrapper, where the entry-word offset shifts.
fn build_show_data(path: &MatchedPath) -> Result<Command, ValidationError> {
Ok(Command::ShowData {
name: require_ident(path, "table_name")?,
filter: build_show_filter(path)?,
limit: build_show_limit(path)?,
})
}
/// The optional `where <expr>` of a `show data`. The expression
/// terminals run from just past `Word("where")` to the start of
/// the `limit` clause (or the end of the path) — neither the
@@ -676,6 +726,39 @@ fn build_delete(path: &MatchedPath) -> Result<Command, ValidationError> {
Ok(Command::Delete { table, filter })
}
/// Build `Command::Explain` (ADR-0028 §1). The matched-word
/// sequence is `[explain, show|update|delete, …]` — the entry
/// word `explain` is at index 0, the inner command's lead word
/// at index 1. The inner command is built by the same builder
/// it uses standalone (`build_show_data` / `build_update` /
/// `build_delete`), all of which are role-based and so are
/// indifferent to the entry-word offset the `explain` prefix
/// introduces.
fn build_explain(path: &MatchedPath) -> Result<Command, ValidationError> {
let inner_word = path
.items
.iter()
.filter_map(|i| match &i.kind {
MatchedKind::Word(w) => Some(*w),
_ => None,
})
.nth(1);
let inner = match inner_word {
Some("show") => build_show_data(path)?,
Some("update") => build_update(path)?,
Some("delete") => build_delete(path)?,
_ => {
return Err(ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown explain target".to_string())],
});
}
};
Ok(Command::Explain {
query: Box::new(inner),
})
}
// =================================================================
// replay — `replay <bare-path>` | `replay '<path>'`
// =================================================================
@@ -744,3 +827,85 @@ pub static REPLAY: CommandNode = CommandNode {
ast_builder: build_replay,
help_id: Some("data.replay"),
usage_ids: &["parse.usage.replay"],};
pub static EXPLAIN: CommandNode = CommandNode {
entry: Word::keyword("explain"),
shape: EXPLAIN_SHAPE,
ast_builder: build_explain,
help_id: Some("data.explain"),
usage_ids: &["parse.usage.explain"],};
// =================================================================
// Tests — `explain` grammar (ADR-0028 §1)
// =================================================================
#[cfg(test)]
mod explain_tests {
use super::Command;
use crate::dsl::parser::parse_command;
/// Parse `input` and unwrap the `Command::Explain` wrapper,
/// returning the inner command.
fn explain_inner(input: &str) -> Command {
match parse_command(input).expect("explain should parse") {
Command::Explain { query } => *query,
other => panic!("expected Command::Explain, got {other:?}"),
}
}
#[test]
fn explain_show_data_wraps_a_show_data() {
assert!(matches!(
explain_inner("explain show data Customers"),
Command::ShowData { .. }
));
}
#[test]
fn explain_show_data_carries_where_and_limit_through() {
match explain_inner("explain show data Customers where id = 1 limit 5") {
Command::ShowData { name, filter, limit } => {
assert_eq!(name, "Customers");
assert!(filter.is_some(), "where clause should survive");
assert_eq!(limit, Some(5));
}
other => panic!("expected ShowData, got {other:?}"),
}
}
#[test]
fn explain_update_wraps_an_update() {
assert!(matches!(
explain_inner("explain update Customers set Name='Bo' where id=1"),
Command::Update { .. }
));
}
#[test]
fn explain_delete_wraps_a_delete() {
assert!(matches!(
explain_inner("explain delete from Customers where id=1"),
Command::Delete { .. }
));
}
#[test]
fn explain_of_an_incomplete_update_is_a_parse_error() {
// A bare `update` still needs its `where` / `--all-rows`
// (ADR-0028 §1: `explain` of an incomplete command is the
// same parse error the command alone would be).
assert!(parse_command("explain update Customers set Name='Bo'").is_err());
}
#[test]
fn explain_does_not_cover_show_table() {
// `explain` covers `show data` only (ADR-0028 §1).
assert!(parse_command("explain show table Customers").is_err());
}
#[test]
fn bare_explain_is_a_parse_error() {
assert!(parse_command("explain").is_err());
assert!(parse_command("explain show").is_err());
}
}