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); self.handle_dsl_query_success(&command, &data);
Vec::new() Vec::new()
} }
AppEvent::DslExplainSucceeded { command, plan } => {
self.handle_dsl_explain_success(&command, &plan);
Vec::new()
}
AppEvent::DslInsertSucceeded { command, result } => { AppEvent::DslInsertSucceeded { command, result } => {
self.handle_dsl_insert_success(&command, &result); self.handle_dsl_insert_success(&command, &result);
Vec::new() 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) { fn handle_dsl_insert_success(&mut self, command: &Command, result: &InsertResult) {
self.note_ok_summary(command); self.note_ok_summary(command);
self.note_system(crate::t!("ok.rows_inserted", count = result.rows_affected)); 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) (Operation::Query, Some(name.as_str()), None)
} }
C::Replay { .. } => (Operation::Replay, None, 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 — // App-lifecycle commands never reach this path —
// `dispatch_input` routes them through // `dispatch_input` routes them through
// `dispatch_app_command` before the DSL execution // `dispatch_app_command` before the DSL execution
@@ -2419,6 +2442,47 @@ mod tests {
assert!(app.output.iter().any(|l| l.text.starts_with("[ok]"))); 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] #[test]
fn replay_command_dispatches_replay_action_not_execute_dsl() { fn replay_command_dispatches_replay_action_not_execute_dsl() {
// Submitting `replay <path>` must NOT produce an // Submitting `replay <path>` must NOT produce an
+430 -59
View File
@@ -32,7 +32,7 @@ use tracing::{debug, info, warn};
use crate::dsl::action::ReferentialAction; use crate::dsl::action::ReferentialAction;
use crate::dsl::command::{ use crate::dsl::command::{
ChangeColumnMode, CompareOp, Expr, IndexSelector, Operand, Predicate, ChangeColumnMode, Command, CompareOp, Expr, IndexSelector, Operand, Predicate,
RelationshipSelector, RowFilter, RelationshipSelector, RowFilter,
}; };
use crate::dsl::ColumnSpec; use crate::dsl::ColumnSpec;
@@ -219,6 +219,30 @@ pub struct DataResult {
pub rows: Vec<Vec<Option<String>>>, pub rows: Vec<Vec<Option<String>>>,
} }
/// One row of an `EXPLAIN QUERY PLAN` result (ADR-0028 §2).
///
/// `id` / `parent` form the plan tree (`parent == 0` is a
/// top-level node); `detail` is the engine's verbatim step
/// description (`SCAN Customers`, `SEARCH … USING INDEX …`).
/// The `notused` column the engine also returns is dropped.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExplainRow {
pub id: i64,
pub parent: i64,
pub detail: String,
}
/// A captured query plan (ADR-0028 §2/§3).
///
/// `display_sql` is the standard-SQL form of the explained
/// statement — shown above the plan tree as part of the
/// simple → advanced bridge. `rows` are the plan-tree nodes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QueryPlan {
pub display_sql: String,
pub rows: Vec<ExplainRow>,
}
/// Outcome of a successful INSERT — a count plus the new row(s) /// Outcome of a successful INSERT — a count plus the new row(s)
/// fetched immediately after so the user can see what landed /// fetched immediately after so the user can see what landed
/// (auto-filled IDs, generated shortids, etc.). /// (auto-filled IDs, generated shortids, etc.).
@@ -515,6 +539,15 @@ enum Request {
source: Option<String>, source: Option<String>,
reply: oneshot::Sender<Result<DataResult, DbError>>, reply: oneshot::Sender<Result<DataResult, DbError>>,
}, },
/// Capture the query plan for an explainable command via
/// `EXPLAIN QUERY PLAN` (ADR-0028 §2). `query` is the inner
/// `ShowData` / `Update` / `Delete`; `EXPLAIN QUERY PLAN`
/// never executes it, so this is read-only even for the
/// destructive variants.
ExplainPlan {
query: Command,
reply: oneshot::Sender<Result<QueryPlan, DbError>>,
},
/// Rebuild the database from `project.yaml` + `data/` /// Rebuild the database from `project.yaml` + `data/`
/// (ADR-0015 §7). Used by the runtime when the `.db` file /// (ADR-0015 §7). Used by the runtime when the `.db` file
/// is missing on project open and by the explicit /// is missing on project open and by the explicit
@@ -909,6 +942,20 @@ impl Database {
recv.await.map_err(|_| DbError::WorkerGone)? recv.await.map_err(|_| DbError::WorkerGone)?
} }
/// Capture the query plan for an explainable command
/// (ADR-0028 §2). The wrapped command is not executed —
/// `EXPLAIN QUERY PLAN` only inspects how the engine would
/// locate the rows — so this is safe even for `update` /
/// `delete`.
pub async fn explain_query_plan(
&self,
query: Command,
) -> Result<QueryPlan, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::ExplainPlan { query, reply }).await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
/// Read both directions of FK relationships for `table`. /// Read both directions of FK relationships for `table`.
/// Used by the runtime's friendly-error enrichment to /// Used by the runtime's friendly-error enrichment to
/// resolve parent / child table names (ADR-0019 §6). /// resolve parent / child table names (ADR-0019 §6).
@@ -1313,6 +1360,9 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
&project_path, &project_path,
)); ));
} }
Request::ExplainPlan { query, reply } => {
let _ = reply.send(do_explain_plan(conn, &query));
}
Request::ReadRelationships { table, reply } => { Request::ReadRelationships { table, reply } => {
let result = do_read_relationships(conn, &table); let result = do_read_relationships(conn, &table);
let _ = reply.send(result); let _ = reply.send(result);
@@ -4536,6 +4586,42 @@ fn do_insert(
}) })
} }
/// Build the parameterised `UPDATE … SET … WHERE …` statement.
/// Separated from `do_update` so the `explain` path runs
/// `EXPLAIN QUERY PLAN` against the exact same statement
/// (ADR-0028 §2). `compile_expr` continues the `?N` numbering
/// from the SET-clause params already pushed.
fn build_update_sql(
schema: &ReadSchema,
table: &str,
assignments: &[(String, Value)],
filter: &RowFilter,
) -> Result<(String, Vec<rusqlite::types::Value>), DbError> {
let mut params: Vec<rusqlite::types::Value> = Vec::new();
let mut set_clauses: Vec<String> = Vec::with_capacity(assignments.len());
for (col, value) in assignments {
let bound = impl_value_for(schema, col, value)?;
set_clauses.push(format!(
"{col_id} = ?{n}",
col_id = quote_ident(col),
n = params.len() + 1
));
params.push(bound_to_sqlite_value(&bound));
}
let where_sql = match filter {
RowFilter::AllRows => String::new(),
RowFilter::Where(expr) => {
format!(" WHERE {}", compile_expr(expr, schema, &mut params))
}
};
let sql = format!(
"UPDATE {ident} SET {sets}{where_sql};",
ident = quote_ident(table),
sets = set_clauses.join(", "),
);
Ok((sql, params))
}
fn do_update( fn do_update(
conn: &Connection, conn: &Connection,
persistence: Option<&Persistence>, persistence: Option<&Persistence>,
@@ -4577,32 +4663,7 @@ fn do_update(
} }
}; };
let mut params: Vec<rusqlite::types::Value> = Vec::new(); let (sql, params) = build_update_sql(&schema, table, assignments, filter)?;
let mut set_clauses: Vec<String> = Vec::with_capacity(assignments.len());
for (col, value) in assignments {
let bound = impl_value_for(&schema, col, value)?;
set_clauses.push(format!(
"{col_id} = ?{n}",
col_id = quote_ident(col),
n = params.len() + 1
));
params.push(bound_to_sqlite_value(&bound));
}
let where_sql = match filter {
RowFilter::AllRows => String::new(),
RowFilter::Where(expr) => {
// `compile_expr` continues the `?N` numbering from
// the SET params already in `params`.
format!(" WHERE {}", compile_expr(expr, &schema, &mut params))
}
};
let sql = format!(
"UPDATE {ident} SET {sets}{where_sql};",
ident = quote_ident(table),
sets = set_clauses.join(", "),
);
debug!(sql = %sql, "update"); debug!(sql = %sql, "update");
let tx = conn let tx = conn
.unchecked_transaction() .unchecked_transaction()
@@ -4639,6 +4700,26 @@ fn select_all_rowids(conn: &Connection, table: &str) -> Result<Vec<i64>, DbError
Ok(ids) Ok(ids)
} }
/// Build the parameterised `DELETE FROM … WHERE …` statement.
/// Separated from `do_delete` so the `explain` path runs
/// `EXPLAIN QUERY PLAN` against the exact same statement
/// (ADR-0028 §2).
fn build_delete_sql(
schema: &ReadSchema,
table: &str,
filter: &RowFilter,
) -> (String, Vec<rusqlite::types::Value>) {
let mut params: Vec<rusqlite::types::Value> = Vec::new();
let where_sql = match filter {
RowFilter::AllRows => String::new(),
RowFilter::Where(expr) => {
format!(" WHERE {}", compile_expr(expr, schema, &mut params))
}
};
let sql = format!("DELETE FROM {ident}{where_sql};", ident = quote_ident(table));
(sql, params)
}
fn do_delete( fn do_delete(
conn: &Connection, conn: &Connection,
persistence: Option<&Persistence>, persistence: Option<&Persistence>,
@@ -4659,17 +4740,7 @@ fn do_delete(
before_counts.push((r.other_table.clone(), count_rows(conn, &r.other_table)?)); before_counts.push((r.other_table.clone(), count_rows(conn, &r.other_table)?));
} }
let mut params: Vec<rusqlite::types::Value> = Vec::new(); let (sql, params) = build_delete_sql(&schema, table, filter);
let where_sql = match filter {
RowFilter::AllRows => String::new(),
RowFilter::Where(expr) => {
format!(" WHERE {}", compile_expr(expr, &schema, &mut params))
}
};
let sql = format!(
"DELETE FROM {ident}{where_sql};",
ident = quote_ident(table),
);
debug!(sql = %sql, "delete"); debug!(sql = %sql, "delete");
let tx = conn let tx = conn
.unchecked_transaction() .unchecked_transaction()
@@ -4727,32 +4798,34 @@ fn do_query_data_request(
Ok(data) Ok(data)
} }
fn do_query_data( /// Build the parameterised `SELECT … FROM …` statement for a
conn: &Connection, /// `show data` query (ADR-0026 §5–§6). Separated from
/// `do_query_data` so the `explain` path runs `EXPLAIN QUERY
/// PLAN` against the *exact* same statement (ADR-0028 §2).
///
/// A `limit` implies a stable primary-key `ORDER BY` so
/// `limit n` is "first n by primary key" rather than an
/// arbitrary subset.
fn build_query_data_sql(
schema: &ReadSchema,
table: &str, table: &str,
filter: Option<&Expr>, filter: Option<&Expr>,
limit: Option<u64>, limit: Option<u64>,
) -> Result<DataResult, DbError> { ) -> (String, Vec<rusqlite::types::Value>) {
let schema = read_schema(conn, table)?; let cols_csv = schema
let column_names: Vec<String> = schema.columns.iter().map(|c| c.name.clone()).collect(); .columns
let column_types: Vec<Option<Type>> =
schema.columns.iter().map(|c| c.user_type).collect();
let cols_csv = column_names
.iter() .iter()
.map(|c| quote_ident(c)) .map(|c| quote_ident(&c.name))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .join(", ");
// WHERE / LIMIT (ADR-0026 §5–§6). A `limit` implies a
// stable primary-key ORDER BY so `limit n` is "first n by
// primary key" rather than an arbitrary subset.
let mut params: Vec<rusqlite::types::Value> = Vec::new(); let mut params: Vec<rusqlite::types::Value> = Vec::new();
let where_sql = filter.map_or_else(String::new, |expr| { let where_sql = filter.map_or_else(String::new, |expr| {
format!(" WHERE {}", compile_expr(expr, &schema, &mut params)) format!(" WHERE {}", compile_expr(expr, schema, &mut params))
}); });
let (order_sql, limit_sql) = match limit { let (order_sql, limit_sql) = limit.map_or_else(
Some(n) => { || (String::new(), String::new()),
|n| {
let order = if schema.primary_key.is_empty() { let order = if schema.primary_key.is_empty() {
String::new() String::new()
} else { } else {
@@ -4768,14 +4841,27 @@ fn do_query_data(
i64::try_from(n).unwrap_or(i64::MAX), i64::try_from(n).unwrap_or(i64::MAX),
)); ));
(order, format!(" LIMIT ?{}", params.len())) (order, format!(" LIMIT ?{}", params.len()))
} },
None => (String::new(), String::new()), );
};
let sql = format!( let sql = format!(
"SELECT {cols} FROM {ident}{where_sql}{order_sql}{limit_sql};", "SELECT {cols_csv} FROM {ident}{where_sql}{order_sql}{limit_sql};",
cols = cols_csv,
ident = quote_ident(table), ident = quote_ident(table),
); );
(sql, params)
}
fn do_query_data(
conn: &Connection,
table: &str,
filter: Option<&Expr>,
limit: Option<u64>,
) -> Result<DataResult, DbError> {
let schema = read_schema(conn, table)?;
let column_names: Vec<String> = schema.columns.iter().map(|c| c.name.clone()).collect();
let column_types: Vec<Option<Type>> =
schema.columns.iter().map(|c| c.user_type).collect();
let (sql, params) = build_query_data_sql(&schema, table, filter, limit);
debug!(sql = %sql, "query_data"); debug!(sql = %sql, "query_data");
let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?; let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?;
let rows_iter = stmt let rows_iter = stmt
@@ -4821,6 +4907,154 @@ fn format_cell(value: rusqlite::types::Value, ty: Option<Type>) -> Option<String
} }
} }
/// Capture the query plan for an explainable command
/// (ADR-0028 §2). Matches the inner command, builds the exact
/// SQL it would otherwise run via the shared `build_*_sql`
/// helpers, runs `EXPLAIN QUERY PLAN` against it (which never
/// executes the statement), and pairs the plan rows with a
/// standard-SQL display form of the statement.
fn do_explain_plan(conn: &Connection, query: &Command) -> Result<QueryPlan, DbError> {
let (exec_sql, params) = match query {
Command::ShowData {
name,
filter,
limit,
} => {
let schema = read_schema(conn, name)?;
build_query_data_sql(&schema, name, filter.as_ref(), *limit)
}
Command::Update {
table,
assignments,
filter,
} => {
let schema = read_schema(conn, table)?;
build_update_sql(&schema, table, assignments, filter)?
}
Command::Delete { table, filter } => {
let schema = read_schema(conn, table)?;
build_delete_sql(&schema, table, filter)
}
other => {
// The grammar only ever wraps the three explainable
// commands; a different inner command means a
// synthesised `Command::Explain` (tests, scripting).
return Err(DbError::Unsupported(format!(
"cannot explain `{}`",
other.verb()
)));
}
};
let rows = run_explain_query_plan(conn, &exec_sql, &params)?;
let display_sql = inline_params_for_display(&exec_sql, &params);
debug!(sql = %display_sql, rows = rows.len(), "explain_plan");
Ok(QueryPlan { display_sql, rows })
}
/// Prepare `EXPLAIN QUERY PLAN <sql>` and read back its tree
/// rows. The inner statement's parameters are bound so the
/// statement prepares cleanly; `EXPLAIN QUERY PLAN` determines
/// the plan from structure, not parameter values (ADR-0028 §2).
fn run_explain_query_plan(
conn: &Connection,
sql: &str,
params: &[rusqlite::types::Value],
) -> Result<Vec<ExplainRow>, DbError> {
let explain_sql = format!("EXPLAIN QUERY PLAN {sql}");
let mut stmt = conn.prepare(&explain_sql).map_err(DbError::from_rusqlite)?;
// `EXPLAIN QUERY PLAN` yields `(id, parent, notused, detail)`
// — the `notused` column at index 2 is dropped.
let rows_iter = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
Ok(ExplainRow {
id: row.get(0)?,
parent: row.get(1)?,
detail: row.get(3)?,
})
})
.map_err(DbError::from_rusqlite)?;
let mut out = Vec::new();
for r in rows_iter {
out.push(r.map_err(DbError::from_rusqlite)?);
}
Ok(out)
}
/// Render execution SQL as human-facing display SQL by inlining
/// each `?N` placeholder as a standard-SQL literal (ADR-0028
/// §3). The scan is quote-aware — a `?N`-shaped run inside a
/// double-quoted identifier is left untouched — and the
/// trailing `;` is dropped so the result reads as a query.
fn inline_params_for_display(sql: &str, params: &[rusqlite::types::Value]) -> String {
let mut out = String::with_capacity(sql.len());
let mut chars = sql.chars().peekable();
let mut in_quote = false;
while let Some(c) = chars.next() {
match c {
'"' => {
in_quote = !in_quote;
out.push('"');
}
'?' if !in_quote && chars.peek().is_some_and(char::is_ascii_digit) => {
let mut digits = String::new();
while let Some(d) = chars.peek() {
if d.is_ascii_digit() {
digits.push(*d);
chars.next();
} else {
break;
}
}
match digits
.parse::<usize>()
.ok()
.and_then(|n| n.checked_sub(1))
.and_then(|idx| params.get(idx))
{
Some(value) => out.push_str(&sql_literal(value)),
// Out of range — leave the placeholder verbatim.
None => {
out.push('?');
out.push_str(&digits);
}
}
}
other => out.push(other),
}
}
out.trim_end().trim_end_matches(';').trim_end().to_string()
}
/// Render a bound parameter as a standard-SQL literal for the
/// display SQL (ADR-0028 §3). Text is single-quoted with
/// embedded quotes doubled; a real with no fractional part
/// keeps a `.0` so it still reads as a real.
fn sql_literal(value: &rusqlite::types::Value) -> String {
use rusqlite::types::Value as V;
match value {
V::Null => "NULL".to_string(),
V::Integer(i) => i.to_string(),
V::Real(r) => {
let s = r.to_string();
if s.contains(['.', 'e', 'E']) {
s
} else {
format!("{s}.0")
}
}
V::Text(s) => format!("'{}'", s.replace('\'', "''")),
V::Blob(bytes) => {
let mut hex = String::with_capacity(bytes.len() * 2 + 3);
hex.push_str("x'");
for b in bytes {
hex.push_str(&format!("{b:02x}"));
}
hex.push('\'');
hex
}
}
}
fn read_relationships_outbound( fn read_relationships_outbound(
conn: &Connection, conn: &Connection,
table: &str, table: &str,
@@ -7885,6 +8119,143 @@ mod tests {
assert_eq!(names(&data), vec!["Bob"]); assert_eq!(names(&data), vec!["Bob"]);
} }
// --- explain / query plans (ADR-0028) -------------------
/// Parse a non-`explain` query command for use as the inner
/// command of `explain_query_plan`.
fn parse_inner(dsl: &str) -> Command {
crate::dsl::parser::parse_command(dsl).expect("inner command parse")
}
#[tokio::test]
async fn explain_show_data_returns_a_scan_plan() {
let db = db();
people_table(&db).await;
let plan = db
.explain_query_plan(parse_inner("show data People where Active = true"))
.await
.unwrap();
assert!(!plan.rows.is_empty(), "a plan has at least one node");
// No index on `Active`, so the filtered query is a full
// table scan.
assert!(
plan.rows.iter().any(|r| r.detail.contains("SCAN")),
"expected a SCAN node, got {:?}",
plan.rows,
);
}
#[tokio::test]
async fn explain_show_data_uses_an_index_when_one_exists() {
let db = db();
people_table(&db).await;
db.add_index(None, "People".to_string(), vec!["Age".to_string()], None)
.await
.unwrap();
let plan = db
.explain_query_plan(parse_inner("show data People where Age = 35"))
.await
.unwrap();
// The teaching payoff: the plan flips from a scan to an
// index search once an index covers the WHERE column.
assert!(
plan.rows.iter().any(|r| r.detail.contains("USING INDEX")),
"expected an index search, got {:?}",
plan.rows,
);
}
#[tokio::test]
async fn explain_delete_does_not_remove_any_rows() {
let db = db();
people_table(&db).await;
let plan = db
.explain_query_plan(parse_inner("delete from People where Active = true"))
.await
.unwrap();
assert!(!plan.rows.is_empty());
// ADR-0028 §1: EXPLAIN QUERY PLAN never executes.
let data = db
.query_data("People".to_string(), None, None, None)
.await
.unwrap();
assert_eq!(data.rows.len(), 4, "explain delete must not delete");
}
#[tokio::test]
async fn explain_update_does_not_change_any_data() {
let db = db();
people_table(&db).await;
db.explain_query_plan(parse_inner(
"update People set Active = false where Active = true",
))
.await
.unwrap();
let (filter, _) = parse_show("show data People where Active = true");
let data = db
.query_data("People".to_string(), filter, None, None)
.await
.unwrap();
// Alice, Carol, Dave are still active — nothing ran.
assert_eq!(data.rows.len(), 3, "explain update must not write");
}
#[tokio::test]
async fn explain_display_sql_inlines_literals_and_quotes_idents() {
let db = db();
people_table(&db).await;
let plan = db
.explain_query_plan(parse_inner("show data People where Name = 'Alice'"))
.await
.unwrap();
assert!(
plan.display_sql.contains("\"People\""),
"table identifier should be double-quoted: {}",
plan.display_sql,
);
assert!(
plan.display_sql.contains("'Alice'"),
"WHERE literal should be inlined: {}",
plan.display_sql,
);
assert!(
!plan.display_sql.contains('?'),
"display SQL must carry no `?` placeholders: {}",
plan.display_sql,
);
}
#[tokio::test]
async fn explain_display_sql_shows_the_implicit_order_by_for_limit() {
let db = db();
people_table(&db).await;
let plan = db
.explain_query_plan(parse_inner("show data People limit 2"))
.await
.unwrap();
// `limit` adds an implicit ORDER BY the primary key
// (ADR-0026 §5) — the display SQL surfaces it.
assert!(
plan.display_sql.contains("ORDER BY"),
"limit implies ORDER BY pk: {}",
plan.display_sql,
);
assert!(
plan.display_sql.contains("LIMIT 2"),
"limit count inlined: {}",
plan.display_sql,
);
}
#[tokio::test]
async fn explain_of_a_missing_table_is_an_error() {
let db = db();
let result = db
.explain_query_plan(parse_inner("show data NoSuchTable"))
.await;
assert!(result.is_err(), "explaining a missing table should fail");
}
#[tokio::test] #[tokio::test]
async fn update_with_all_rows_affects_everything() { async fn update_with_all_rows_affects_everything() {
let db = db(); let db = db();
+14
View File
@@ -166,6 +166,16 @@ pub enum Command {
Replay { Replay {
path: String, 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 /// App-lifecycle command (per ADR-0003). These work in both
/// simple and advanced modes; the dispatcher branches on the /// simple and advanced modes; the dispatcher branches on the
/// `Command::App(...)` variant before mode-specific routing. /// `Command::App(...)` variant before mode-specific routing.
@@ -457,6 +467,7 @@ impl Command {
Self::Delete { .. } => "delete from", Self::Delete { .. } => "delete from",
Self::ShowData { .. } => "show data", Self::ShowData { .. } => "show data",
Self::Replay { .. } => "replay", Self::Replay { .. } => "replay",
Self::Explain { .. } => "explain",
Self::App(app) => match app { Self::App(app) => match app {
AppCommand::Quit => "quit", AppCommand::Quit => "quit",
AppCommand::Help => "help", AppCommand::Help => "help",
@@ -514,6 +525,9 @@ impl Command {
// Replay isn't tied to a single table; the path is // Replay isn't tied to a single table; the path is
// the most identifying thing for log output. // the most identifying thing for log output.
Self::Replay { path } => path, 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 // App commands aren't tied to schema entities — the
// verb is the most identifying thing. The // verb is the most identifying thing. The
// display_subject override below provides a richer // display_subject override below provides a richer
+170 -5
View File
@@ -310,6 +310,48 @@ const DELETE_NODES: &[Node] = &[
]; ];
const DELETE_SHAPE: Node = Node::Seq(DELETE_NODES); 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 // AST builders
// ================================================================= // =================================================================
@@ -355,11 +397,7 @@ fn build_show(path: &MatchedPath) -> Result<Command, ValidationError> {
.nth(1); .nth(1);
let name = require_ident(path, "table_name")?; let name = require_ident(path, "table_name")?;
match sub { match sub {
Some("data") => Ok(Command::ShowData { Some("data") => build_show_data(path),
name,
filter: build_show_filter(path)?,
limit: build_show_limit(path)?,
}),
Some("table") => Ok(Command::ShowTable { name }), Some("table") => Ok(Command::ShowTable { name }),
_ => Err(ValidationError { _ => Err(ValidationError {
message_key: "parse.error_wrapper", 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 /// The optional `where <expr>` of a `show data`. The expression
/// terminals run from just past `Word("where")` to the start of /// terminals run from just past `Word("where")` to the start of
/// the `limit` clause (or the end of the path) — neither the /// 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 }) 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>'` // replay — `replay <bare-path>` | `replay '<path>'`
// ================================================================= // =================================================================
@@ -744,3 +827,85 @@ pub static REPLAY: CommandNode = CommandNode {
ast_builder: build_replay, ast_builder: build_replay,
help_id: Some("data.replay"), help_id: Some("data.replay"),
usage_ids: &["parse.usage.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());
}
}
+1
View File
@@ -484,6 +484,7 @@ pub static REGISTRY: &[&CommandNode] = &[
&data::UPDATE, &data::UPDATE,
&data::DELETE, &data::DELETE,
&data::REPLAY, &data::REPLAY,
&data::EXPLAIN,
]; ];
/// Look up a `CommandNode` by entry word, case-insensitively. /// Look up a `CommandNode` by entry word, case-insensitively.
+4 -1
View File
@@ -9,7 +9,7 @@ use crossterm::event::KeyEvent;
use crate::db::{ use crate::db::{
AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult, AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult,
DropColumnResult, InsertResult, TableDescription, UpdateResult, DropColumnResult, InsertResult, QueryPlan, TableDescription, UpdateResult,
}; };
use crate::dsl::Command; use crate::dsl::Command;
@@ -30,6 +30,9 @@ pub enum AppEvent {
}, },
/// A `show data` query succeeded. /// A `show data` query succeeded.
DslDataSucceeded { command: Command, data: DataResult }, DslDataSucceeded { command: Command, data: DataResult },
/// An `explain …` command succeeded (ADR-0028). `plan`
/// carries the captured query plan; nothing was executed.
DslExplainSucceeded { command: Command, plan: QueryPlan },
DslInsertSucceeded { DslInsertSucceeded {
command: Command, command: Command,
result: InsertResult, result: InsertResult,
+2
View File
@@ -151,6 +151,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("help.data.update", &[]), ("help.data.update", &[]),
("help.data.delete", &[]), ("help.data.delete", &[]),
("help.data.replay", &[]), ("help.data.replay", &[]),
("help.data.explain", &[]),
// ---- Hint panel ambient typing assistance (ADR-0022 §6) ---- // ---- Hint panel ambient typing assistance (ADR-0022 §6) ----
("hint.ambient_complete", &[]), ("hint.ambient_complete", &[]),
( (
@@ -214,6 +215,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("parse.usage.drop_index", &[]), ("parse.usage.drop_index", &[]),
("parse.usage.drop_relationship", &[]), ("parse.usage.drop_relationship", &[]),
("parse.usage.drop_table", &[]), ("parse.usage.drop_table", &[]),
("parse.usage.explain", &[]),
("parse.usage.insert", &[]), ("parse.usage.insert", &[]),
("parse.usage.rename_column", &[]), ("parse.usage.rename_column", &[]),
("parse.usage.export", &[]), ("parse.usage.export", &[]),
+8
View File
@@ -281,6 +281,10 @@ help:
replay <path> — run each non-blank, non-`#`-comment line of <path> replay <path> — run each non-blank, non-`#`-comment line of <path>
as a command. Stops at the first error (no rollback); as a command. Stops at the first error (no rollback);
relative paths resolve under the project directory. relative paths resolve under the project directory.
explain: |-
explain show data <T> | explain update <T> ... | explain delete from <T> ...
— show how the database would run a query, without
running it (safe even for update / delete)
# Type reference, appended after the command list. # Type reference, appended after the command list.
types_reference: | types_reference: |
Types: text, int, real, decimal, bool, date, datetime, blob, serial, shortid Types: text, int, real, decimal, bool, date, datetime, blob, serial, shortid
@@ -440,6 +444,10 @@ parse:
insert: "insert into <Table> [(<col>[, ...])] [values] (<value>[, ...])" insert: "insert into <Table> [(<col>[, ...])] [values] (<value>[, ...])"
update: "update <Table> set <col>=<value>[, ...] (where <col>=<value> | --all-rows)" update: "update <Table> set <col>=<value>[, ...] (where <col>=<value> | --all-rows)"
delete: "delete from <Table> (where <col>=<value> | --all-rows)" delete: "delete from <Table> (where <col>=<value> | --all-rows)"
explain: |-
explain show data <Table> [where <expr>] [limit <n>]
explain update <Table> set <col>=<value>[, ...] (where <expr> | --all-rows)
explain delete from <Table> (where <expr> | --all-rows)
replay: "replay <path> | replay '<path with spaces>'" replay: "replay <path> | replay '<path with spaces>'"
# App-lifecycle commands (per ADR-0003, surfaced through # App-lifecycle commands (per ADR-0003, surfaced through
# the parser so they participate in usage templates + # the parser so they participate in usage templates +
+128 -1
View File
@@ -20,8 +20,12 @@
//! respectively (display-only — underlying data is //! respectively (display-only — underlying data is
//! untouched). //! untouched).
use crate::db::{ColumnDescription, DataResult, TableDescription}; use std::collections::HashSet;
use crate::app::{OutputKind, OutputLine};
use crate::db::{ColumnDescription, DataResult, ExplainRow, QueryPlan, TableDescription};
use crate::dsl::Type; use crate::dsl::Type;
use crate::mode::Mode;
const NULL_DISPLAY: &str = "(null)"; const NULL_DISPLAY: &str = "(null)";
@@ -148,6 +152,72 @@ pub fn render_structure(desc: &TableDescription) -> Vec<String> {
out out
} }
/// Render a captured query plan as a box-drawing tree
/// (ADR-0028 §3).
///
/// The standard-SQL display form of the explained statement is
/// shown first; the plan tree follows, built from each row's
/// `id` / `parent` links. Node text is the engine's `detail`
/// string **verbatim** — nothing is reworded.
///
/// Unlike the `Vec<String>`-returning renderers above this
/// returns ready-built `OutputLine`s, because a plan line
/// carries per-span styling (ADR-0028 §5). Each line is
/// stamped with `mode` for the submission-mode tag.
#[must_use]
pub fn render_explain_plan(plan: &QueryPlan, mode: Mode) -> Vec<OutputLine> {
let mut out: Vec<OutputLine> = Vec::with_capacity(plan.rows.len() + 1);
out.push(plain_plan_line(plan.display_sql.clone(), mode));
// `emitted` guards against a malformed plan with a cyclic
// or self-referential `parent` link — every node is drawn
// at most once.
let mut emitted: HashSet<i64> = HashSet::new();
render_plan_subtree(&plan.rows, 0, "", &mut out, &mut emitted, mode);
out
}
/// Append the subtree rooted at `parent` (`0` = top level) to
/// `out`, drawing box-drawing connectors. `prefix` is the
/// indent accumulated from ancestor levels.
fn render_plan_subtree(
rows: &[ExplainRow],
parent: i64,
prefix: &str,
out: &mut Vec<OutputLine>,
emitted: &mut HashSet<i64>,
mode: Mode,
) {
let children: Vec<&ExplainRow> =
rows.iter().filter(|r| r.parent == parent).collect();
let last_idx = children.len().saturating_sub(1);
for (idx, row) in children.iter().enumerate() {
if !emitted.insert(row.id) {
continue;
}
let is_last = idx == last_idx;
let connector = if is_last { "└─ " } else { "├─ " };
out.push(plain_plan_line(
format!("{prefix}{connector}{}", row.detail),
mode,
));
let child_prefix =
format!("{prefix}{}", if is_last { " " } else { "" });
render_plan_subtree(rows, row.id, &child_prefix, out, emitted, mode);
}
}
/// A plain (unstyled) plan output line. ADR-0028 step 4 swaps
/// the tree lines for span-styled ones; the display-SQL line
/// stays plain.
const fn plain_plan_line(text: String, mode: Mode) -> OutputLine {
OutputLine {
text,
kind: OutputKind::System,
mode_at_submission: mode,
styled_runs: None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Alignment { pub enum Alignment {
Left, Left,
@@ -647,6 +717,63 @@ mod tests {
assert!(!out.contains("Indexes:"), "got:\n{out}"); assert!(!out.contains("Indexes:"), "got:\n{out}");
} }
// --- render_explain_plan (ADR-0028 §3) --------------
#[test]
fn render_explain_plan_puts_display_sql_first() {
let plan = QueryPlan {
display_sql: "SELECT \"id\" FROM \"T\"".to_string(),
rows: vec![ExplainRow {
id: 2,
parent: 0,
detail: "SCAN T".to_string(),
}],
};
let lines = render_explain_plan(&plan, Mode::Simple);
assert_eq!(lines[0].text, "SELECT \"id\" FROM \"T\"");
assert!(lines[1].text.contains("SCAN T"), "got {:?}", lines[1].text);
assert!(lines[1].text.contains('└'), "last node uses └─");
}
#[test]
fn render_explain_plan_nests_children_under_parents() {
let plan = QueryPlan {
display_sql: "SELECT 1".to_string(),
rows: vec![
ExplainRow { id: 1, parent: 0, detail: "root".to_string() },
ExplainRow { id: 2, parent: 1, detail: "child-a".to_string() },
ExplainRow { id: 3, parent: 1, detail: "child-b".to_string() },
],
};
let lines = render_explain_plan(&plan, Mode::Simple);
// display SQL + 3 plan nodes.
assert_eq!(lines.len(), 4);
assert!(lines[1].text.contains("root"));
assert!(lines[2].text.contains("├─ child-a"), "got {:?}", lines[2].text);
assert!(lines[3].text.contains("└─ child-b"), "got {:?}", lines[3].text);
// The single root uses `└─`; its children are indented
// by three spaces (no `│` spine, the root being last).
assert!(lines[1].text.starts_with("└─ root"));
assert!(lines[2].text.starts_with(" ├─ child-a"));
}
#[test]
fn render_explain_plan_survives_a_self_referential_node() {
// A malformed plan node that is both a root (`parent ==
// 0`) and its own parent (`id == 0`) must not loop.
let plan = QueryPlan {
display_sql: String::new(),
rows: vec![ExplainRow {
id: 0,
parent: 0,
detail: "self-rooted".to_string(),
}],
};
let lines = render_explain_plan(&plan, Mode::Simple);
// display-SQL line + the node, drawn exactly once.
assert_eq!(lines.len(), 2);
}
#[test] #[test]
fn render_structure_falls_back_to_sqlite_type_when_user_type_missing() { fn render_structure_falls_back_to_sqlite_type_when_user_type_missing() {
let mut desc = TableDescription { let mut desc = TableDescription {
+14 -1
View File
@@ -30,7 +30,7 @@ use crate::app::App;
use crate::cli::Args; use crate::cli::Args;
use crate::db::{ use crate::db::{
AddColumnResult, ChangeColumnTypeResult, DataResult, Database, DbError, DeleteResult, AddColumnResult, ChangeColumnTypeResult, DataResult, Database, DbError, DeleteResult,
DropColumnResult, InsertResult, TableDescription, UpdateResult, DropColumnResult, InsertResult, QueryPlan, TableDescription, UpdateResult,
}; };
use crate::dsl::Command; use crate::dsl::Command;
use crate::dsl::walker::Severity; use crate::dsl::walker::Severity;
@@ -1143,6 +1143,10 @@ fn spawn_dsl_dispatch(
command: command.clone(), command: command.clone(),
data, data,
}, },
Ok(CommandOutcome::QueryPlan(plan)) => AppEvent::DslExplainSucceeded {
command: command.clone(),
plan,
},
Ok(CommandOutcome::Insert(result)) => AppEvent::DslInsertSucceeded { Ok(CommandOutcome::Insert(result)) => AppEvent::DslInsertSucceeded {
command: command.clone(), command: command.clone(),
result, result,
@@ -1490,6 +1494,7 @@ fn parse_qualified_target(message: &str) -> Option<(String, String)> {
enum CommandOutcome { enum CommandOutcome {
Schema(Option<TableDescription>), Schema(Option<TableDescription>),
Query(DataResult), Query(DataResult),
QueryPlan(QueryPlan),
Insert(InsertResult), Insert(InsertResult),
Update(UpdateResult), Update(UpdateResult),
Delete(DeleteResult), Delete(DeleteResult),
@@ -1797,6 +1802,14 @@ async fn execute_command_typed(
.query_data(name, filter, limit, src) .query_data(name, filter, limit, src)
.await .await
.map(CommandOutcome::Query), .map(CommandOutcome::Query),
// `EXPLAIN QUERY PLAN` never executes the wrapped
// statement (ADR-0028 §2), so explaining a destructive
// command is safe. `src` is unused here — explain is a
// diagnostic and is not written to `history.log`.
Command::Explain { query } => database
.explain_query_plan(*query)
.await
.map(CommandOutcome::QueryPlan),
// `replay` is parsed as a DSL command but routed by // `replay` is parsed as a DSL command but routed by
// App::dispatch_dsl as `Action::Replay` rather than // App::dispatch_dsl as `Action::Replay` rather than
// `Action::ExecuteDsl`; it never reaches the worker // `Action::ExecuteDsl`; it never reaches the worker
+1
View File
@@ -213,6 +213,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String {
Delete { .. } => "Delete".into(), Delete { .. } => "Delete".into(),
ShowData { .. } => "ShowData".into(), ShowData { .. } => "ShowData".into(),
Replay { .. } => "Replay".into(), Replay { .. } => "Replay".into(),
Explain { .. } => "Explain".into(),
App(app) => match app { App(app) => match app {
AppCommand::Quit => "App(Quit)".into(), AppCommand::Quit => "App(Quit)".into(),
AppCommand::Help => "App(Help)".into(), AppCommand::Help => "App(Help)".into(),