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:
+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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user