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:
@@ -166,6 +166,16 @@ pub enum Command {
|
||||
Replay {
|
||||
path: String,
|
||||
},
|
||||
/// Capture and display the query plan for an explainable
|
||||
/// command without executing it (ADR-0028). The inner
|
||||
/// `Command` is an ordinary parsed `ShowData` / `Update` /
|
||||
/// `Delete`; the runtime recognizes the `Explain` wrapper
|
||||
/// and routes it to the plan path instead of normal
|
||||
/// execution. Because `EXPLAIN QUERY PLAN` never runs the
|
||||
/// statement, explaining a destructive command is safe.
|
||||
Explain {
|
||||
query: Box<Self>,
|
||||
},
|
||||
/// App-lifecycle command (per ADR-0003). These work in both
|
||||
/// simple and advanced modes; the dispatcher branches on the
|
||||
/// `Command::App(...)` variant before mode-specific routing.
|
||||
@@ -457,6 +467,7 @@ impl Command {
|
||||
Self::Delete { .. } => "delete from",
|
||||
Self::ShowData { .. } => "show data",
|
||||
Self::Replay { .. } => "replay",
|
||||
Self::Explain { .. } => "explain",
|
||||
Self::App(app) => match app {
|
||||
AppCommand::Quit => "quit",
|
||||
AppCommand::Help => "help",
|
||||
@@ -514,6 +525,9 @@ impl Command {
|
||||
// Replay isn't tied to a single table; the path is
|
||||
// the most identifying thing for log output.
|
||||
Self::Replay { path } => path,
|
||||
// Explain forwards to the wrapped query — the table
|
||||
// the plan is about is the inner command's table.
|
||||
Self::Explain { query } => query.target_table(),
|
||||
// App commands aren't tied to schema entities — the
|
||||
// verb is the most identifying thing. The
|
||||
// display_subject override below provides a richer
|
||||
|
||||
+170
-5
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,6 +484,7 @@ pub static REGISTRY: &[&CommandNode] = &[
|
||||
&data::UPDATE,
|
||||
&data::DELETE,
|
||||
&data::REPLAY,
|
||||
&data::EXPLAIN,
|
||||
];
|
||||
|
||||
/// Look up a `CommandNode` by entry word, case-insensitively.
|
||||
|
||||
Reference in New Issue
Block a user