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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user