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);
|
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
|
||||||
|
|||||||
@@ -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, ¶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(
|
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();
|
||||||
|
|||||||
@@ -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
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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", &[]),
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user