From d17addddd75f5892cfb5b0aa61effa455d550a35 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Tue, 19 May 2026 12:38:02 +0000 Subject: [PATCH] =?UTF-8?q?explain:=20`explain`=20command=20end=20to=20end?= =?UTF-8?q?=20(ADR-0028=20steps=202=E2=80=933)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 }`; `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. --- src/app.rs | 64 +++++ src/db.rs | 489 ++++++++++++++++++++++++++++---- src/dsl/command.rs | 14 + src/dsl/grammar/data.rs | 175 +++++++++++- src/dsl/grammar/mod.rs | 1 + src/event.rs | 5 +- src/friendly/keys.rs | 2 + src/friendly/strings/en-US.yaml | 8 + src/output_render.rs | 129 ++++++++- src/runtime.rs | 15 +- tests/typing_surface/mod.rs | 1 + 11 files changed, 836 insertions(+), 67 deletions(-) diff --git a/src/app.rs b/src/app.rs index 45632c3..926d73e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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 ` must NOT produce an diff --git a/src/db.rs b/src/db.rs index 2fd8d61..e9152f8 100644 --- a/src/db.rs +++ b/src/db.rs @@ -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>>, } +/// 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, +} + /// 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, reply: oneshot::Sender>, }, + /// 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>, + }, /// 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 { + 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), DbError> { + let mut params: Vec = Vec::new(); + let mut set_clauses: Vec = 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 = Vec::new(); - let mut set_clauses: Vec = 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, 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) { + let mut params: Vec = 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 = 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, -) -> Result { - let schema = read_schema(conn, table)?; - let column_names: Vec = schema.columns.iter().map(|c| c.name.clone()).collect(); - let column_types: Vec> = - schema.columns.iter().map(|c| c.user_type).collect(); - - let cols_csv = column_names +) -> (String, Vec) { + let cols_csv = schema + .columns .iter() - .map(|c| quote_ident(c)) + .map(|c| quote_ident(&c.name)) .collect::>() .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 = 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, +) -> Result { + let schema = read_schema(conn, table)?; + let column_names: Vec = schema.columns.iter().map(|c| c.name.clone()).collect(); + let column_types: Vec> = + 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) -> Option Result { + 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 ` 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, 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::() + .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(); diff --git a/src/dsl/command.rs b/src/dsl/command.rs index 3420cd7..1686e88 100644 --- a/src/dsl/command.rs +++ b/src/dsl/command.rs @@ -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, + }, /// 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 diff --git a/src/dsl/grammar/data.rs b/src/dsl/grammar/data.rs index f75d98c..3aae0da 100644 --- a/src/dsl/grammar/data.rs +++ b/src/dsl/grammar/data.rs @@ -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 { .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 { } } +/// 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 { + Ok(Command::ShowData { + name: require_ident(path, "table_name")?, + filter: build_show_filter(path)?, + limit: build_show_limit(path)?, + }) +} + /// The optional `where ` 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 { 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 { + 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 ` | `replay ''` // ================================================================= @@ -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()); + } +} diff --git a/src/dsl/grammar/mod.rs b/src/dsl/grammar/mod.rs index a041e15..db4da62 100644 --- a/src/dsl/grammar/mod.rs +++ b/src/dsl/grammar/mod.rs @@ -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. diff --git a/src/event.rs b/src/event.rs index 28b2290..ae6ac28 100644 --- a/src/event.rs +++ b/src/event.rs @@ -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, diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index 3d13921..9561e7b 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -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", &[]), diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index 386da3c..01caa6c 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -281,6 +281,10 @@ help: replay — run each non-blank, non-`#`-comment line of as a command. Stops at the first error (no rollback); relative paths resolve under the project directory. + explain: |- + explain show data | explain update ... | explain delete from ... + — 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 [([, ...])] [values] ([, ...])" update: "update
set =[, ...] (where = | --all-rows)" delete: "delete from
(where = | --all-rows)" + explain: |- + explain show data
[where ] [limit ] + explain update
set =[, ...] (where | --all-rows) + explain delete from
(where | --all-rows) replay: "replay | replay ''" # App-lifecycle commands (per ADR-0003, surfaced through # the parser so they participate in usage templates + diff --git a/src/output_render.rs b/src/output_render.rs index 8c24c63..de069fc 100644 --- a/src/output_render.rs +++ b/src/output_render.rs @@ -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 { 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`-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 { + let mut out: Vec = 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 = 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, + emitted: &mut HashSet, + 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 { diff --git a/src/runtime.rs b/src/runtime.rs index 9921b6e..1a4efb6 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -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), 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 diff --git a/tests/typing_surface/mod.rs b/tests/typing_surface/mod.rs index 96840e6..a3d4dbc 100644 --- a/tests/typing_surface/mod.rs +++ b/tests/typing_surface/mod.rs @@ -213,6 +213,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String { Delete { .. } => "Delete".into(), ShowData { .. } => "ShowData".into(), Replay { .. } => "Replay".into(), + Explain { .. } => "Explain".into(), App(app) => match app { AppCommand::Quit => "App(Quit)".into(), AppCommand::Help => "App(Help)".into(),