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:
+64
@@ -398,6 +398,10 @@ impl App {
|
||||
self.handle_dsl_query_success(&command, &data);
|
||||
Vec::new()
|
||||
}
|
||||
AppEvent::DslExplainSucceeded { command, plan } => {
|
||||
self.handle_dsl_explain_success(&command, &plan);
|
||||
Vec::new()
|
||||
}
|
||||
AppEvent::DslInsertSucceeded { command, result } => {
|
||||
self.handle_dsl_insert_success(&command, &result);
|
||||
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) {
|
||||
self.note_ok_summary(command);
|
||||
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)
|
||||
}
|
||||
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 —
|
||||
// `dispatch_input` routes them through
|
||||
// `dispatch_app_command` before the DSL execution
|
||||
@@ -2419,6 +2442,47 @@ mod tests {
|
||||
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]
|
||||
fn replay_command_dispatches_replay_action_not_execute_dsl() {
|
||||
// Submitting `replay <path>` must NOT produce an
|
||||
|
||||
@@ -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, ¶ms)?;
|
||||
let display_sql = inline_params_for_display(&exec_sql, ¶ms);
|
||||
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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
+4
-1
@@ -9,7 +9,7 @@ use crossterm::event::KeyEvent;
|
||||
|
||||
use crate::db::{
|
||||
AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult,
|
||||
DropColumnResult, InsertResult, TableDescription, UpdateResult,
|
||||
DropColumnResult, InsertResult, QueryPlan, TableDescription, UpdateResult,
|
||||
};
|
||||
use crate::dsl::Command;
|
||||
|
||||
@@ -30,6 +30,9 @@ pub enum AppEvent {
|
||||
},
|
||||
/// A `show data` query succeeded.
|
||||
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 {
|
||||
command: Command,
|
||||
result: InsertResult,
|
||||
|
||||
@@ -151,6 +151,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("help.data.update", &[]),
|
||||
("help.data.delete", &[]),
|
||||
("help.data.replay", &[]),
|
||||
("help.data.explain", &[]),
|
||||
// ---- Hint panel ambient typing assistance (ADR-0022 §6) ----
|
||||
("hint.ambient_complete", &[]),
|
||||
(
|
||||
@@ -214,6 +215,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("parse.usage.drop_index", &[]),
|
||||
("parse.usage.drop_relationship", &[]),
|
||||
("parse.usage.drop_table", &[]),
|
||||
("parse.usage.explain", &[]),
|
||||
("parse.usage.insert", &[]),
|
||||
("parse.usage.rename_column", &[]),
|
||||
("parse.usage.export", &[]),
|
||||
|
||||
@@ -281,6 +281,10 @@ help:
|
||||
replay <path> — run each non-blank, non-`#`-comment line of <path>
|
||||
as a command. Stops at the first error (no rollback);
|
||||
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.
|
||||
types_reference: |
|
||||
Types: text, int, real, decimal, bool, date, datetime, blob, serial, shortid
|
||||
@@ -440,6 +444,10 @@ parse:
|
||||
insert: "insert into <Table> [(<col>[, ...])] [values] (<value>[, ...])"
|
||||
update: "update <Table> set <col>=<value>[, ...] (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>'"
|
||||
# App-lifecycle commands (per ADR-0003, surfaced through
|
||||
# the parser so they participate in usage templates +
|
||||
|
||||
+128
-1
@@ -20,8 +20,12 @@
|
||||
//! respectively (display-only — underlying data is
|
||||
//! 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::mode::Mode;
|
||||
|
||||
const NULL_DISPLAY: &str = "(null)";
|
||||
|
||||
@@ -148,6 +152,72 @@ pub fn render_structure(desc: &TableDescription) -> Vec<String> {
|
||||
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)]
|
||||
pub enum Alignment {
|
||||
Left,
|
||||
@@ -647,6 +717,63 @@ mod tests {
|
||||
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]
|
||||
fn render_structure_falls_back_to_sqlite_type_when_user_type_missing() {
|
||||
let mut desc = TableDescription {
|
||||
|
||||
+14
-1
@@ -30,7 +30,7 @@ use crate::app::App;
|
||||
use crate::cli::Args;
|
||||
use crate::db::{
|
||||
AddColumnResult, ChangeColumnTypeResult, DataResult, Database, DbError, DeleteResult,
|
||||
DropColumnResult, InsertResult, TableDescription, UpdateResult,
|
||||
DropColumnResult, InsertResult, QueryPlan, TableDescription, UpdateResult,
|
||||
};
|
||||
use crate::dsl::Command;
|
||||
use crate::dsl::walker::Severity;
|
||||
@@ -1143,6 +1143,10 @@ fn spawn_dsl_dispatch(
|
||||
command: command.clone(),
|
||||
data,
|
||||
},
|
||||
Ok(CommandOutcome::QueryPlan(plan)) => AppEvent::DslExplainSucceeded {
|
||||
command: command.clone(),
|
||||
plan,
|
||||
},
|
||||
Ok(CommandOutcome::Insert(result)) => AppEvent::DslInsertSucceeded {
|
||||
command: command.clone(),
|
||||
result,
|
||||
@@ -1490,6 +1494,7 @@ fn parse_qualified_target(message: &str) -> Option<(String, String)> {
|
||||
enum CommandOutcome {
|
||||
Schema(Option<TableDescription>),
|
||||
Query(DataResult),
|
||||
QueryPlan(QueryPlan),
|
||||
Insert(InsertResult),
|
||||
Update(UpdateResult),
|
||||
Delete(DeleteResult),
|
||||
@@ -1797,6 +1802,14 @@ async fn execute_command_typed(
|
||||
.query_data(name, filter, limit, src)
|
||||
.await
|
||||
.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
|
||||
// App::dispatch_dsl as `Action::Replay` rather than
|
||||
// `Action::ExecuteDsl`; it never reaches the worker
|
||||
|
||||
Reference in New Issue
Block a user