feat: support explain over advanced-mode SQL queries
explain now wraps the advanced SQL commands — select, with (CTE), insert, update, delete — in addition to the DSL show data/update/ delete it already covered, rendering through the same plan tree (ADR-0039, closing the ADR-0030 OOS-2 gap). Implemented as a second Advanced `explain` CommandNode under the shared entry word, reusing the established shared-word dispatch (SQL-first, DSL-fallback) rather than new grammar machinery. build_explain_sql slices the inner SQL off the source and reuses the existing SQL builders; do_explain_plan runs EXPLAIN QUERY PLAN over the carried text verbatim (never executes, so safe for destructive verbs). Advanced explain update/delete now route through SQL with an identical plan; DSL-explain tests pinned to simple mode. Help and usage text now list the advanced explain forms.
This commit is contained in:
+254
-5
@@ -431,6 +431,46 @@ const EXPLAIN_CHOICES: &[Node] = &[
|
||||
];
|
||||
const EXPLAIN_SHAPE: Node = Node::Choice(EXPLAIN_CHOICES);
|
||||
|
||||
// --- explain over advanced-mode SQL (ADR-0039) -------------------
|
||||
//
|
||||
// The SQL inner mirrors the DSL inner above, but wraps the SQL
|
||||
// command shapes (the same nodes the standalone `SELECT` / `WITH` /
|
||||
// `SQL_*` commands use). This shape backs a *second* `explain`
|
||||
// CommandNode (`EXPLAIN_SQL`, registered `Advanced`); the registry's
|
||||
// shared-entry-word dispatch tries it first in advanced mode and
|
||||
// falls back to the `Simple` DSL `EXPLAIN` when a branch can't match
|
||||
// (e.g. `explain show data …`, or a DSL-only `--all-rows`). `select`
|
||||
// and `with` are SQL-only, so they only ever resolve here.
|
||||
|
||||
const EXPLAIN_SELECT_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("select")),
|
||||
Node::Subgrammar(&sql_select::SQL_SELECT_TAIL),
|
||||
];
|
||||
const EXPLAIN_WITH_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("with")),
|
||||
Node::Subgrammar(&sql_select::SQL_WITH_TAIL),
|
||||
];
|
||||
const EXPLAIN_SQL_INSERT_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("insert")),
|
||||
Node::Subgrammar(&sql_insert::SQL_INSERT_SHAPE),
|
||||
];
|
||||
const EXPLAIN_SQL_UPDATE_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("update")),
|
||||
Node::Subgrammar(&sql_update::SQL_UPDATE_SHAPE),
|
||||
];
|
||||
const EXPLAIN_SQL_DELETE_NODES: &[Node] = &[
|
||||
Node::Word(Word::keyword("delete")),
|
||||
Node::Subgrammar(&sql_delete::SQL_DELETE_SHAPE),
|
||||
];
|
||||
const EXPLAIN_SQL_CHOICES: &[Node] = &[
|
||||
Node::Seq(EXPLAIN_SELECT_NODES),
|
||||
Node::Seq(EXPLAIN_WITH_NODES),
|
||||
Node::Seq(EXPLAIN_SQL_INSERT_NODES),
|
||||
Node::Seq(EXPLAIN_SQL_UPDATE_NODES),
|
||||
Node::Seq(EXPLAIN_SQL_DELETE_NODES),
|
||||
];
|
||||
const EXPLAIN_SQL_SHAPE: Node = Node::Choice(EXPLAIN_SQL_CHOICES);
|
||||
|
||||
// =================================================================
|
||||
// select — SQL `SELECT` (advanced mode; ADR-0030 §6, ADR-0031)
|
||||
// =================================================================
|
||||
@@ -876,6 +916,48 @@ fn build_explain(path: &MatchedPath, _source: &str) -> Result<Command, Validatio
|
||||
})
|
||||
}
|
||||
|
||||
/// Build `Command::Explain` over an advanced-mode SQL inner
|
||||
/// (ADR-0039). The inner SQL text is sliced from `source` starting
|
||||
/// at the inner entry keyword's span, so the carried SQL excludes
|
||||
/// the `explain` prefix — `EXPLAIN QUERY PLAN` runs over the inner
|
||||
/// statement, not the wrapper. The SQL builders extract their
|
||||
/// metadata (target table, etc.) from `path` by role, which is
|
||||
/// offset-independent, so passing the whole explain `path` is safe;
|
||||
/// only the SQL *text* needs the prefix stripped.
|
||||
fn build_explain_sql(path: &MatchedPath, source: &str) -> Result<Command, ValidationError> {
|
||||
// Words in the path: [0] is the `explain` entry word, [1] is the
|
||||
// inner entry keyword (select / with / insert / update / delete).
|
||||
let inner_item = path
|
||||
.items
|
||||
.iter()
|
||||
.filter(|i| matches!(i.kind, MatchedKind::Word(_)))
|
||||
.nth(1)
|
||||
.ok_or_else(|| ValidationError {
|
||||
message_key: "parse.error_wrapper",
|
||||
args: vec![("detail", "missing explain target".to_string())],
|
||||
})?;
|
||||
let inner_word = match &inner_item.kind {
|
||||
MatchedKind::Word(w) => *w,
|
||||
_ => unreachable!("filtered to Word above"),
|
||||
};
|
||||
let inner_source = source[inner_item.span.0..].trim();
|
||||
let inner = match inner_word {
|
||||
"select" | "with" => build_select(path, inner_source)?,
|
||||
"insert" => build_sql_insert(path, inner_source)?,
|
||||
"update" => build_sql_update(path, inner_source)?,
|
||||
"delete" => build_sql_delete(path, inner_source)?,
|
||||
_ => {
|
||||
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>'`
|
||||
// =================================================================
|
||||
@@ -1317,6 +1399,25 @@ pub static EXPLAIN: CommandNode = CommandNode {
|
||||
help_id: Some("data.explain"),
|
||||
usage_ids: &["parse.usage.explain"],};
|
||||
|
||||
/// `explain` over advanced-mode SQL (ADR-0039).
|
||||
///
|
||||
/// The `Advanced` node of the shared `explain` entry word. Pairs with
|
||||
/// the `Simple` DSL [`EXPLAIN`] node above: in advanced mode the
|
||||
/// dispatcher tries this SQL node first and falls back to the DSL node
|
||||
/// when no SQL branch matches (`explain show data …`, or a DSL-only
|
||||
/// `--all-rows`); in simple mode only the DSL node is reachable.
|
||||
pub static EXPLAIN_SQL: CommandNode = CommandNode {
|
||||
entry: Word::keyword("explain"),
|
||||
shape: EXPLAIN_SQL_SHAPE,
|
||||
ast_builder: build_explain_sql,
|
||||
// No `help_id` / `usage_ids` — this is the `Advanced` half of the
|
||||
// shared `explain` entry word, so it defers to the `Simple`
|
||||
// `EXPLAIN` node's help/usage (which now covers the SQL forms
|
||||
// too). Mirrors the `SQL_INSERT`/`SQL_UPDATE`/`SQL_DELETE`
|
||||
// precedent; otherwise `note_help` would print `explain` twice.
|
||||
help_id: None,
|
||||
usage_ids: &[],};
|
||||
|
||||
/// SQL `SELECT` (ADR-0030 §6, ADR-0031, ADR-0032).
|
||||
///
|
||||
/// Advanced mode only — gated by `grammar::is_advanced_only`.
|
||||
@@ -1401,10 +1502,17 @@ mod explain_tests {
|
||||
use super::Command;
|
||||
use crate::dsl::parser::parse_command;
|
||||
|
||||
/// Parse `input` and unwrap the `Command::Explain` wrapper,
|
||||
/// returning the inner command.
|
||||
/// Parse `input` in **simple** mode and unwrap the
|
||||
/// `Command::Explain` wrapper, returning the inner command.
|
||||
/// These cover the DSL-explain wrapping (ADR-0028); the
|
||||
/// advanced-mode SQL wrapping (ADR-0039) is covered by
|
||||
/// `explain_inner_adv` below. (`parse_command` defaults to
|
||||
/// advanced, where `explain update`/`delete` now route to the
|
||||
/// SQL path — so DSL-explain tests pin the mode explicitly.)
|
||||
fn explain_inner(input: &str) -> Command {
|
||||
match parse_command(input).expect("explain should parse") {
|
||||
match crate::dsl::parser::parse_command_in_mode(input, crate::mode::Mode::Simple)
|
||||
.expect("explain should parse")
|
||||
{
|
||||
Command::Explain { query } => *query,
|
||||
other => panic!("expected Command::Explain, got {other:?}"),
|
||||
}
|
||||
@@ -1450,8 +1558,15 @@ mod explain_tests {
|
||||
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());
|
||||
// same parse error the command alone would be). Simple mode:
|
||||
// in advanced mode a where-less SQL UPDATE is valid (ADR-0039).
|
||||
assert!(
|
||||
crate::dsl::parser::parse_command_in_mode(
|
||||
"explain update Customers set Name='Bo'",
|
||||
crate::mode::Mode::Simple,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1465,4 +1580,138 @@ mod explain_tests {
|
||||
assert!(parse_command("explain").is_err());
|
||||
assert!(parse_command("explain show").is_err());
|
||||
}
|
||||
|
||||
// ---- ADR-0039: explain over advanced-mode SQL --------------
|
||||
|
||||
use crate::dsl::parser::parse_command_in_mode;
|
||||
use crate::mode::Mode;
|
||||
|
||||
/// Advanced-mode counterpart of `explain_inner`.
|
||||
fn explain_inner_adv(input: &str) -> Command {
|
||||
match parse_command_in_mode(input, Mode::Advanced)
|
||||
.expect("advanced explain should parse")
|
||||
{
|
||||
Command::Explain { query } => *query,
|
||||
other => panic!("expected Command::Explain, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explain_select_wraps_a_select_with_clean_sql() {
|
||||
// The carried SQL must NOT include the `explain` prefix
|
||||
// (ADR-0039) — `EXPLAIN QUERY PLAN` runs over the inner SQL.
|
||||
match explain_inner_adv("explain select * from Customers") {
|
||||
Command::Select { sql } => assert_eq!(sql, "select * from Customers"),
|
||||
other => panic!("expected Select, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explain_with_cte_wraps_a_select() {
|
||||
match explain_inner_adv(
|
||||
"explain with recent as (select * from Orders) select * from recent",
|
||||
) {
|
||||
Command::Select { sql } => {
|
||||
assert!(sql.starts_with("with recent"), "clean inner sql: {sql}");
|
||||
}
|
||||
other => panic!("expected Select, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explain_sql_insert_wraps_a_sql_insert() {
|
||||
match explain_inner_adv("explain insert into Customers values (1, 'Bo')") {
|
||||
Command::SqlInsert { sql, target_table, .. } => {
|
||||
assert_eq!(target_table, "Customers");
|
||||
assert_eq!(sql, "insert into Customers values (1, 'Bo')");
|
||||
}
|
||||
other => panic!("expected SqlInsert, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explain_sql_update_wraps_a_sql_update_with_clean_sql() {
|
||||
match explain_inner_adv("explain update Customers set Name = 'Bo' where id = 1") {
|
||||
Command::SqlUpdate { sql, target_table, .. } => {
|
||||
assert_eq!(target_table, "Customers");
|
||||
assert_eq!(sql, "update Customers set Name = 'Bo' where id = 1");
|
||||
}
|
||||
other => panic!("expected SqlUpdate, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explain_sql_delete_wraps_a_sql_delete() {
|
||||
match explain_inner_adv("explain delete from Customers where id = 1") {
|
||||
Command::SqlDelete { sql, target_table, .. } => {
|
||||
assert_eq!(target_table, "Customers");
|
||||
assert_eq!(sql, "delete from Customers where id = 1");
|
||||
}
|
||||
other => panic!("expected SqlDelete, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explain_update_with_all_rows_flag_falls_back_to_dsl_in_advanced() {
|
||||
// `--all-rows` is DSL-only; the SQL update shape can't
|
||||
// consume it, so the explain inner falls back to the DSL
|
||||
// `Update` node — mirroring the top-level shared-word
|
||||
// dispatch (ADR-0033).
|
||||
assert!(matches!(
|
||||
explain_inner_adv("explain update Customers set Name = 'Bo' --all-rows"),
|
||||
Command::Update { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explain_show_data_still_uses_dsl_in_advanced() {
|
||||
// `show data` has no SQL form; advanced `explain show data`
|
||||
// falls back to the DSL inner.
|
||||
assert!(matches!(
|
||||
explain_inner_adv("explain show data Customers"),
|
||||
Command::ShowData { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explain_select_is_rejected_in_simple_mode() {
|
||||
// `select` is advanced-only, so `explain select` has no
|
||||
// simple-mode form.
|
||||
assert!(parse_command_in_mode("explain select * from Customers", Mode::Simple).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explain_does_not_cover_ddl() {
|
||||
// EXPLAIN QUERY PLAN applies to DML/queries only (ADR-0039
|
||||
// out of scope); there is no SQL DDL branch under explain.
|
||||
assert!(parse_command_in_mode(
|
||||
"explain create table T (id int)",
|
||||
Mode::Advanced,
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_explain_completion_offers_the_sql_verbs() {
|
||||
// After `explain ` in advanced mode the candidate list is the
|
||||
// union across both `explain` CommandNodes: the SQL verbs
|
||||
// (select/with/insert/update/delete) plus the DSL `show`
|
||||
// (ADR-0039). The shared-entry-word completion already
|
||||
// aggregates, so there is no UX gap.
|
||||
use crate::completion::candidates_at_cursor_in_mode;
|
||||
let schema = crate::completion::SchemaCache::default();
|
||||
let input = "explain ";
|
||||
let completion =
|
||||
candidates_at_cursor_in_mode(input, input.len(), &schema, Mode::Advanced)
|
||||
.expect("explain offers candidates");
|
||||
let names: Vec<&str> = completion
|
||||
.candidates
|
||||
.iter()
|
||||
.map(|c| c.text.as_str())
|
||||
.collect();
|
||||
for verb in ["select", "with", "insert", "update", "delete", "show"] {
|
||||
assert!(names.contains(&verb), "expected `{verb}` in {names:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -634,6 +634,13 @@ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
|
||||
(&data::SQL_INSERT, CommandCategory::Advanced),
|
||||
(&data::SQL_UPDATE, CommandCategory::Advanced),
|
||||
(&data::SQL_DELETE, CommandCategory::Advanced),
|
||||
// Shared entry word `explain` (ADR-0039): the `Simple` DSL
|
||||
// `data::EXPLAIN` (above) wraps `show data` / `update` / `delete`;
|
||||
// this `Advanced` node wraps the SQL `select` / `with` / `insert`
|
||||
// / `update` / `delete`. SQL-first / DSL-fallback in advanced mode
|
||||
// (so `explain show data …` and DSL-only `--all-rows` still reach
|
||||
// the DSL node); DSL-only in simple mode.
|
||||
(&data::EXPLAIN_SQL, CommandCategory::Advanced),
|
||||
// Shared entry word `create` (ADR-0035 §2): the simple
|
||||
// `ddl::CREATE` (above) and these advanced SQL nodes. The
|
||||
// dispatcher tries the advanced candidates first in advanced mode
|
||||
@@ -782,4 +789,22 @@ mod usage_key_tests {
|
||||
Some("parse.usage.create_table"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_two_registered_commands_share_a_help_id() {
|
||||
// `note_help` emits one help block per `help_id: Some(_)`
|
||||
// with no dedup, so a duplicate help_id prints the same
|
||||
// command twice in `help`. Shared-entry-word `Advanced`
|
||||
// nodes (SQL_INSERT, …, EXPLAIN_SQL) therefore carry
|
||||
// `help_id: None` and defer to their `Simple` sibling.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (command, _category) in super::REGISTRY {
|
||||
if let Some(id) = command.help_id {
|
||||
assert!(
|
||||
seen.insert(id),
|
||||
"duplicate help_id `{id}` in REGISTRY would print twice in `help`",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user