feat: support explain over advanced-mode SQL queries

explain now wraps the advanced SQL commands — select, with (CTE),
insert, update, delete — in addition to the DSL show data/update/
delete it already covered, rendering through the same plan tree
(ADR-0039, closing the ADR-0030 OOS-2 gap).

Implemented as a second Advanced `explain` CommandNode under the
shared entry word, reusing the established shared-word dispatch
(SQL-first, DSL-fallback) rather than new grammar machinery.
build_explain_sql slices the inner SQL off the source and reuses the
existing SQL builders; do_explain_plan runs EXPLAIN QUERY PLAN over
the carried text verbatim (never executes, so safe for destructive
verbs). Advanced explain update/delete now route through SQL with an
identical plan; DSL-explain tests pinned to simple mode. Help and
usage text now list the advanced explain forms.
This commit is contained in:
claude@clouddev1
2026-05-30 18:44:05 +00:00
parent f7ca288fe1
commit f62cccec55
8 changed files with 503 additions and 14 deletions
+166
View File
@@ -9211,6 +9211,16 @@ fn do_explain_plan(conn: &Connection, query: &Command) -> Result<QueryPlan, DbEr
let schema = read_schema(conn, table)?;
build_delete_sql(&schema, table, filter)
}
// ADR-0039: advanced-mode SQL commands carry their validated
// SQL text verbatim (grammar-as-text), so there is nothing to
// synthesise — run EXPLAIN QUERY PLAN over the text directly,
// with no bound parameters. `EXPLAIN QUERY PLAN` never
// executes the statement, so this is safe for the destructive
// verbs too.
Command::Select { sql }
| Command::SqlInsert { sql, .. }
| Command::SqlUpdate { sql, .. }
| Command::SqlDelete { sql, .. } => (sql.clone(), Vec::new()),
other => {
// The grammar only ever wraps the three explainable
// commands; a different inner command means a
@@ -12436,6 +12446,13 @@ mod tests {
.expect("inner command parse")
}
/// Advanced-mode counterpart of `parse_inner` (ADR-0039): parses
/// a bare SQL command (the inner of an `explain`).
fn parse_inner_adv(sql: &str) -> Command {
crate::dsl::parser::parse_command_in_mode(sql, crate::mode::Mode::Advanced)
.expect("inner SQL command parse")
}
#[tokio::test]
async fn explain_show_data_returns_a_scan_plan() {
let db = db();
@@ -12587,6 +12604,155 @@ mod tests {
assert!(result.is_err(), "explaining a missing table should fail");
}
// --- ADR-0039: explain over advanced-mode SQL -------------
#[tokio::test]
async fn explain_sql_select_returns_a_scan_plan() {
let db = db();
people_table(&db).await;
let plan = db
.explain_query_plan(parse_inner_adv("select * from People where Name = 'Bob'"))
.await
.unwrap();
assert!(!plan.rows.is_empty(), "a plan has at least one node");
assert!(
plan.rows.iter().any(|r| r.detail.contains("SCAN")),
"expected a SCAN node, got {:?}",
plan.rows,
);
}
#[tokio::test]
async fn explain_sql_select_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_adv("select * from People where Age = 35"))
.await
.unwrap();
assert!(
plan.rows.iter().any(|r| r.detail.contains("USING INDEX")),
"expected an index search, got {:?}",
plan.rows,
);
}
#[tokio::test]
async fn explain_sql_delete_does_not_remove_any_rows() {
let db = db();
people_table(&db).await;
db.explain_query_plan(parse_inner_adv("delete from People where Age = 35"))
.await
.unwrap();
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_sql_update_does_not_change_any_data() {
let db = db();
people_table(&db).await;
db.explain_query_plan(parse_inner_adv(
"update People set Name = 'Zed' where Age = 35",
))
.await
.unwrap();
let data = db
.query_data("People".to_string(), None, None, None)
.await
.unwrap();
assert!(
!data.rows.iter().flatten().any(|c| c.as_deref() == Some("Zed")),
"explain update must not modify rows",
);
}
#[tokio::test]
async fn explain_sql_with_cte_returns_a_plan() {
let db = db();
people_table(&db).await;
let plan = db
.explain_query_plan(parse_inner_adv(
"with adults as (select * from People where Age = 35) select * from adults",
))
.await
.unwrap();
assert!(!plan.rows.is_empty(), "a WITH query has a plan");
}
#[tokio::test]
async fn explain_sql_insert_does_not_add_a_row() {
// EXPLAIN QUERY PLAN over an INSERT must not execute it
// (ADR-0039). A VALUES insert has a trivial plan (it may be
// empty), so we assert the call succeeds and the table is
// unchanged rather than asserting a node count.
let db = db();
people_table(&db).await;
db.explain_query_plan(parse_inner_adv(
"insert into People (Name, Age, Active) values ('Zed', 1, true)",
))
.await
.unwrap();
let data = db
.query_data("People".to_string(), None, None, None)
.await
.unwrap();
assert_eq!(data.rows.len(), 4, "explain insert must not insert");
}
#[tokio::test]
async fn explain_sql_insert_from_select_returns_a_plan() {
// INSERT … SELECT has a real query plan (the SELECT source),
// so this exercises the non-trivial insert-plan path.
let db = db();
people_table(&db).await;
let plan = db
.explain_query_plan(parse_inner_adv(
"insert into People (Name, Age, Active) select Name, Age, Active from People where Age = 35",
))
.await
.unwrap();
assert!(
plan.rows.iter().any(|r| r.detail.contains("SCAN")),
"insert-from-select plans the SELECT source, got {:?}",
plan.rows,
);
let data = db
.query_data("People".to_string(), None, None, None)
.await
.unwrap();
assert_eq!(data.rows.len(), 4, "explain insert-select must not insert");
}
#[tokio::test]
async fn explain_sql_display_sql_is_the_verbatim_query() {
// The SQL path carries the user's text grammar-as-text, so
// the display SQL is verbatim (no canonicalisation), unlike
// the DSL path which synthesises canonical SQL.
let db = db();
people_table(&db).await;
let plan = db
.explain_query_plan(parse_inner_adv("select * from People where Age = 35"))
.await
.unwrap();
assert_eq!(plan.display_sql, "select * from People where Age = 35");
}
#[tokio::test]
async fn explain_sql_of_a_missing_table_is_an_error() {
let db = db();
let result = db
.explain_query_plan(parse_inner_adv("select * from NoSuchTable"))
.await;
assert!(result.is_err(), "explaining a missing SQL table should fail");
}
// --- column constraints at create-table (ADR-0029) ------
/// A `ColumnSpec` carrying the four constraint slots.