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
+430 -59
View File
@@ -32,7 +32,7 @@ use tracing::{debug, info, warn};
use crate::dsl::action::ReferentialAction;
use crate::dsl::command::{
ChangeColumnMode, CompareOp, Expr, IndexSelector, Operand, Predicate,
ChangeColumnMode, Command, CompareOp, Expr, IndexSelector, Operand, Predicate,
RelationshipSelector, RowFilter,
};
use crate::dsl::ColumnSpec;
@@ -219,6 +219,30 @@ pub struct DataResult {
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)
/// fetched immediately after so the user can see what landed
/// (auto-filled IDs, generated shortids, etc.).
@@ -515,6 +539,15 @@ enum Request {
source: Option<String>,
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/`
/// (ADR-0015 §7). Used by the runtime when the `.db` file
/// is missing on project open and by the explicit
@@ -909,6 +942,20 @@ impl Database {
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`.
/// Used by the runtime's friendly-error enrichment to
/// resolve parent / child table names (ADR-0019 §6).
@@ -1313,6 +1360,9 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
&project_path,
));
}
Request::ExplainPlan { query, reply } => {
let _ = reply.send(do_explain_plan(conn, &query));
}
Request::ReadRelationships { table, reply } => {
let result = do_read_relationships(conn, &table);
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(
conn: &Connection,
persistence: Option<&Persistence>,
@@ -4577,32 +4663,7 @@ fn do_update(
}
};
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) => {
// `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(", "),
);
let (sql, params) = build_update_sql(&schema, table, assignments, filter)?;
debug!(sql = %sql, "update");
let tx = conn
.unchecked_transaction()
@@ -4639,6 +4700,26 @@ fn select_all_rowids(conn: &Connection, table: &str) -> Result<Vec<i64>, DbError
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(
conn: &Connection,
persistence: Option<&Persistence>,
@@ -4659,17 +4740,7 @@ fn do_delete(
before_counts.push((r.other_table.clone(), count_rows(conn, &r.other_table)?));
}
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),
);
let (sql, params) = build_delete_sql(&schema, table, filter);
debug!(sql = %sql, "delete");
let tx = conn
.unchecked_transaction()
@@ -4727,32 +4798,34 @@ fn do_query_data_request(
Ok(data)
}
fn do_query_data(
conn: &Connection,
/// Build the parameterised `SELECT … FROM …` statement for a
/// `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,
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 cols_csv = column_names
) -> (String, Vec<rusqlite::types::Value>) {
let cols_csv = schema
.columns
.iter()
.map(|c| quote_ident(c))
.map(|c| quote_ident(&c.name))
.collect::<Vec<_>>()
.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 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 {
Some(n) => {
let (order_sql, limit_sql) = limit.map_or_else(
|| (String::new(), String::new()),
|n| {
let order = if schema.primary_key.is_empty() {
String::new()
} else {
@@ -4768,14 +4841,27 @@ fn do_query_data(
i64::try_from(n).unwrap_or(i64::MAX),
));
(order, format!(" LIMIT ?{}", params.len()))
}
None => (String::new(), String::new()),
};
},
);
let sql = format!(
"SELECT {cols} FROM {ident}{where_sql}{order_sql}{limit_sql};",
cols = cols_csv,
"SELECT {cols_csv} FROM {ident}{where_sql}{order_sql}{limit_sql};",
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");
let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?;
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(
conn: &Connection,
table: &str,
@@ -7885,6 +8119,143 @@ mod tests {
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]
async fn update_with_all_rows_affects_everything() {
let db = db();