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:
@@ -32,7 +32,7 @@ use tracing::{debug, info, warn};
|
||||
|
||||
use crate::dsl::action::ReferentialAction;
|
||||
use crate::dsl::command::{
|
||||
ChangeColumnMode, CompareOp, Expr, IndexSelector, Operand, Predicate,
|
||||
ChangeColumnMode, Command, CompareOp, Expr, IndexSelector, Operand, Predicate,
|
||||
RelationshipSelector, RowFilter,
|
||||
};
|
||||
use crate::dsl::ColumnSpec;
|
||||
@@ -219,6 +219,30 @@ pub struct DataResult {
|
||||
pub rows: Vec<Vec<Option<String>>>,
|
||||
}
|
||||
|
||||
/// One row of an `EXPLAIN QUERY PLAN` result (ADR-0028 §2).
|
||||
///
|
||||
/// `id` / `parent` form the plan tree (`parent == 0` is a
|
||||
/// top-level node); `detail` is the engine's verbatim step
|
||||
/// description (`SCAN Customers`, `SEARCH … USING INDEX …`).
|
||||
/// The `notused` column the engine also returns is dropped.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ExplainRow {
|
||||
pub id: i64,
|
||||
pub parent: i64,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// A captured query plan (ADR-0028 §2/§3).
|
||||
///
|
||||
/// `display_sql` is the standard-SQL form of the explained
|
||||
/// statement — shown above the plan tree as part of the
|
||||
/// simple → advanced bridge. `rows` are the plan-tree nodes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct QueryPlan {
|
||||
pub display_sql: String,
|
||||
pub rows: Vec<ExplainRow>,
|
||||
}
|
||||
|
||||
/// Outcome of a successful INSERT — a count plus the new row(s)
|
||||
/// fetched immediately after so the user can see what landed
|
||||
/// (auto-filled IDs, generated shortids, etc.).
|
||||
@@ -515,6 +539,15 @@ enum Request {
|
||||
source: Option<String>,
|
||||
reply: oneshot::Sender<Result<DataResult, DbError>>,
|
||||
},
|
||||
/// Capture the query plan for an explainable command via
|
||||
/// `EXPLAIN QUERY PLAN` (ADR-0028 §2). `query` is the inner
|
||||
/// `ShowData` / `Update` / `Delete`; `EXPLAIN QUERY PLAN`
|
||||
/// never executes it, so this is read-only even for the
|
||||
/// destructive variants.
|
||||
ExplainPlan {
|
||||
query: Command,
|
||||
reply: oneshot::Sender<Result<QueryPlan, DbError>>,
|
||||
},
|
||||
/// Rebuild the database from `project.yaml` + `data/`
|
||||
/// (ADR-0015 §7). Used by the runtime when the `.db` file
|
||||
/// is missing on project open and by the explicit
|
||||
@@ -909,6 +942,20 @@ impl Database {
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
/// Capture the query plan for an explainable command
|
||||
/// (ADR-0028 §2). The wrapped command is not executed —
|
||||
/// `EXPLAIN QUERY PLAN` only inspects how the engine would
|
||||
/// locate the rows — so this is safe even for `update` /
|
||||
/// `delete`.
|
||||
pub async fn explain_query_plan(
|
||||
&self,
|
||||
query: Command,
|
||||
) -> Result<QueryPlan, DbError> {
|
||||
let (reply, recv) = oneshot::channel();
|
||||
self.send(Request::ExplainPlan { query, reply }).await?;
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
/// Read both directions of FK relationships for `table`.
|
||||
/// Used by the runtime's friendly-error enrichment to
|
||||
/// resolve parent / child table names (ADR-0019 §6).
|
||||
@@ -1313,6 +1360,9 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
|
||||
&project_path,
|
||||
));
|
||||
}
|
||||
Request::ExplainPlan { query, reply } => {
|
||||
let _ = reply.send(do_explain_plan(conn, &query));
|
||||
}
|
||||
Request::ReadRelationships { table, reply } => {
|
||||
let result = do_read_relationships(conn, &table);
|
||||
let _ = reply.send(result);
|
||||
@@ -4536,6 +4586,42 @@ fn do_insert(
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the parameterised `UPDATE … SET … WHERE …` statement.
|
||||
/// Separated from `do_update` so the `explain` path runs
|
||||
/// `EXPLAIN QUERY PLAN` against the exact same statement
|
||||
/// (ADR-0028 §2). `compile_expr` continues the `?N` numbering
|
||||
/// from the SET-clause params already pushed.
|
||||
fn build_update_sql(
|
||||
schema: &ReadSchema,
|
||||
table: &str,
|
||||
assignments: &[(String, Value)],
|
||||
filter: &RowFilter,
|
||||
) -> Result<(String, Vec<rusqlite::types::Value>), DbError> {
|
||||
let mut params: Vec<rusqlite::types::Value> = Vec::new();
|
||||
let mut set_clauses: Vec<String> = Vec::with_capacity(assignments.len());
|
||||
for (col, value) in assignments {
|
||||
let bound = impl_value_for(schema, col, value)?;
|
||||
set_clauses.push(format!(
|
||||
"{col_id} = ?{n}",
|
||||
col_id = quote_ident(col),
|
||||
n = params.len() + 1
|
||||
));
|
||||
params.push(bound_to_sqlite_value(&bound));
|
||||
}
|
||||
let where_sql = match filter {
|
||||
RowFilter::AllRows => String::new(),
|
||||
RowFilter::Where(expr) => {
|
||||
format!(" WHERE {}", compile_expr(expr, schema, &mut params))
|
||||
}
|
||||
};
|
||||
let sql = format!(
|
||||
"UPDATE {ident} SET {sets}{where_sql};",
|
||||
ident = quote_ident(table),
|
||||
sets = set_clauses.join(", "),
|
||||
);
|
||||
Ok((sql, params))
|
||||
}
|
||||
|
||||
fn do_update(
|
||||
conn: &Connection,
|
||||
persistence: Option<&Persistence>,
|
||||
@@ -4577,32 +4663,7 @@ fn do_update(
|
||||
}
|
||||
};
|
||||
|
||||
let mut params: Vec<rusqlite::types::Value> = Vec::new();
|
||||
let mut set_clauses: Vec<String> = Vec::with_capacity(assignments.len());
|
||||
for (col, value) in assignments {
|
||||
let bound = impl_value_for(&schema, col, value)?;
|
||||
set_clauses.push(format!(
|
||||
"{col_id} = ?{n}",
|
||||
col_id = quote_ident(col),
|
||||
n = params.len() + 1
|
||||
));
|
||||
params.push(bound_to_sqlite_value(&bound));
|
||||
}
|
||||
|
||||
let where_sql = match filter {
|
||||
RowFilter::AllRows => String::new(),
|
||||
RowFilter::Where(expr) => {
|
||||
// `compile_expr` continues the `?N` numbering from
|
||||
// the SET params already in `params`.
|
||||
format!(" WHERE {}", compile_expr(expr, &schema, &mut params))
|
||||
}
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"UPDATE {ident} SET {sets}{where_sql};",
|
||||
ident = quote_ident(table),
|
||||
sets = set_clauses.join(", "),
|
||||
);
|
||||
let (sql, params) = build_update_sql(&schema, table, assignments, filter)?;
|
||||
debug!(sql = %sql, "update");
|
||||
let tx = conn
|
||||
.unchecked_transaction()
|
||||
@@ -4639,6 +4700,26 @@ fn select_all_rowids(conn: &Connection, table: &str) -> Result<Vec<i64>, DbError
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// Build the parameterised `DELETE FROM … WHERE …` statement.
|
||||
/// Separated from `do_delete` so the `explain` path runs
|
||||
/// `EXPLAIN QUERY PLAN` against the exact same statement
|
||||
/// (ADR-0028 §2).
|
||||
fn build_delete_sql(
|
||||
schema: &ReadSchema,
|
||||
table: &str,
|
||||
filter: &RowFilter,
|
||||
) -> (String, Vec<rusqlite::types::Value>) {
|
||||
let mut params: Vec<rusqlite::types::Value> = Vec::new();
|
||||
let where_sql = match filter {
|
||||
RowFilter::AllRows => String::new(),
|
||||
RowFilter::Where(expr) => {
|
||||
format!(" WHERE {}", compile_expr(expr, schema, &mut params))
|
||||
}
|
||||
};
|
||||
let sql = format!("DELETE FROM {ident}{where_sql};", ident = quote_ident(table));
|
||||
(sql, params)
|
||||
}
|
||||
|
||||
fn do_delete(
|
||||
conn: &Connection,
|
||||
persistence: Option<&Persistence>,
|
||||
@@ -4659,17 +4740,7 @@ fn do_delete(
|
||||
before_counts.push((r.other_table.clone(), count_rows(conn, &r.other_table)?));
|
||||
}
|
||||
|
||||
let mut params: Vec<rusqlite::types::Value> = Vec::new();
|
||||
let where_sql = match filter {
|
||||
RowFilter::AllRows => String::new(),
|
||||
RowFilter::Where(expr) => {
|
||||
format!(" WHERE {}", compile_expr(expr, &schema, &mut params))
|
||||
}
|
||||
};
|
||||
let sql = format!(
|
||||
"DELETE FROM {ident}{where_sql};",
|
||||
ident = quote_ident(table),
|
||||
);
|
||||
let (sql, params) = build_delete_sql(&schema, table, filter);
|
||||
debug!(sql = %sql, "delete");
|
||||
let tx = conn
|
||||
.unchecked_transaction()
|
||||
@@ -4727,32 +4798,34 @@ fn do_query_data_request(
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
fn do_query_data(
|
||||
conn: &Connection,
|
||||
/// Build the parameterised `SELECT … FROM …` statement for a
|
||||
/// `show data` query (ADR-0026 §5–§6). Separated from
|
||||
/// `do_query_data` so the `explain` path runs `EXPLAIN QUERY
|
||||
/// PLAN` against the *exact* same statement (ADR-0028 §2).
|
||||
///
|
||||
/// A `limit` implies a stable primary-key `ORDER BY` so
|
||||
/// `limit n` is "first n by primary key" rather than an
|
||||
/// arbitrary subset.
|
||||
fn build_query_data_sql(
|
||||
schema: &ReadSchema,
|
||||
table: &str,
|
||||
filter: Option<&Expr>,
|
||||
limit: Option<u64>,
|
||||
) -> Result<DataResult, DbError> {
|
||||
let schema = read_schema(conn, table)?;
|
||||
let column_names: Vec<String> = schema.columns.iter().map(|c| c.name.clone()).collect();
|
||||
let column_types: Vec<Option<Type>> =
|
||||
schema.columns.iter().map(|c| c.user_type).collect();
|
||||
|
||||
let cols_csv = column_names
|
||||
) -> (String, Vec<rusqlite::types::Value>) {
|
||||
let cols_csv = schema
|
||||
.columns
|
||||
.iter()
|
||||
.map(|c| quote_ident(c))
|
||||
.map(|c| quote_ident(&c.name))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
// WHERE / LIMIT (ADR-0026 §5–§6). A `limit` implies a
|
||||
// stable primary-key ORDER BY so `limit n` is "first n by
|
||||
// primary key" rather than an arbitrary subset.
|
||||
let mut params: Vec<rusqlite::types::Value> = Vec::new();
|
||||
let where_sql = filter.map_or_else(String::new, |expr| {
|
||||
format!(" WHERE {}", compile_expr(expr, &schema, &mut params))
|
||||
format!(" WHERE {}", compile_expr(expr, schema, &mut params))
|
||||
});
|
||||
let (order_sql, limit_sql) = match limit {
|
||||
Some(n) => {
|
||||
let (order_sql, limit_sql) = limit.map_or_else(
|
||||
|| (String::new(), String::new()),
|
||||
|n| {
|
||||
let order = if schema.primary_key.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
@@ -4768,14 +4841,27 @@ fn do_query_data(
|
||||
i64::try_from(n).unwrap_or(i64::MAX),
|
||||
));
|
||||
(order, format!(" LIMIT ?{}", params.len()))
|
||||
}
|
||||
None => (String::new(), String::new()),
|
||||
};
|
||||
},
|
||||
);
|
||||
let sql = format!(
|
||||
"SELECT {cols} FROM {ident}{where_sql}{order_sql}{limit_sql};",
|
||||
cols = cols_csv,
|
||||
"SELECT {cols_csv} FROM {ident}{where_sql}{order_sql}{limit_sql};",
|
||||
ident = quote_ident(table),
|
||||
);
|
||||
(sql, params)
|
||||
}
|
||||
|
||||
fn do_query_data(
|
||||
conn: &Connection,
|
||||
table: &str,
|
||||
filter: Option<&Expr>,
|
||||
limit: Option<u64>,
|
||||
) -> Result<DataResult, DbError> {
|
||||
let schema = read_schema(conn, table)?;
|
||||
let column_names: Vec<String> = schema.columns.iter().map(|c| c.name.clone()).collect();
|
||||
let column_types: Vec<Option<Type>> =
|
||||
schema.columns.iter().map(|c| c.user_type).collect();
|
||||
|
||||
let (sql, params) = build_query_data_sql(&schema, table, filter, limit);
|
||||
debug!(sql = %sql, "query_data");
|
||||
let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?;
|
||||
let rows_iter = stmt
|
||||
@@ -4821,6 +4907,154 @@ fn format_cell(value: rusqlite::types::Value, ty: Option<Type>) -> Option<String
|
||||
}
|
||||
}
|
||||
|
||||
/// Capture the query plan for an explainable command
|
||||
/// (ADR-0028 §2). Matches the inner command, builds the exact
|
||||
/// SQL it would otherwise run via the shared `build_*_sql`
|
||||
/// helpers, runs `EXPLAIN QUERY PLAN` against it (which never
|
||||
/// executes the statement), and pairs the plan rows with a
|
||||
/// standard-SQL display form of the statement.
|
||||
fn do_explain_plan(conn: &Connection, query: &Command) -> Result<QueryPlan, DbError> {
|
||||
let (exec_sql, params) = match query {
|
||||
Command::ShowData {
|
||||
name,
|
||||
filter,
|
||||
limit,
|
||||
} => {
|
||||
let schema = read_schema(conn, name)?;
|
||||
build_query_data_sql(&schema, name, filter.as_ref(), *limit)
|
||||
}
|
||||
Command::Update {
|
||||
table,
|
||||
assignments,
|
||||
filter,
|
||||
} => {
|
||||
let schema = read_schema(conn, table)?;
|
||||
build_update_sql(&schema, table, assignments, filter)?
|
||||
}
|
||||
Command::Delete { table, filter } => {
|
||||
let schema = read_schema(conn, table)?;
|
||||
build_delete_sql(&schema, table, filter)
|
||||
}
|
||||
other => {
|
||||
// The grammar only ever wraps the three explainable
|
||||
// commands; a different inner command means a
|
||||
// synthesised `Command::Explain` (tests, scripting).
|
||||
return Err(DbError::Unsupported(format!(
|
||||
"cannot explain `{}`",
|
||||
other.verb()
|
||||
)));
|
||||
}
|
||||
};
|
||||
let rows = run_explain_query_plan(conn, &exec_sql, ¶ms)?;
|
||||
let display_sql = inline_params_for_display(&exec_sql, ¶ms);
|
||||
debug!(sql = %display_sql, rows = rows.len(), "explain_plan");
|
||||
Ok(QueryPlan { display_sql, rows })
|
||||
}
|
||||
|
||||
/// Prepare `EXPLAIN QUERY PLAN <sql>` and read back its tree
|
||||
/// rows. The inner statement's parameters are bound so the
|
||||
/// statement prepares cleanly; `EXPLAIN QUERY PLAN` determines
|
||||
/// the plan from structure, not parameter values (ADR-0028 §2).
|
||||
fn run_explain_query_plan(
|
||||
conn: &Connection,
|
||||
sql: &str,
|
||||
params: &[rusqlite::types::Value],
|
||||
) -> Result<Vec<ExplainRow>, DbError> {
|
||||
let explain_sql = format!("EXPLAIN QUERY PLAN {sql}");
|
||||
let mut stmt = conn.prepare(&explain_sql).map_err(DbError::from_rusqlite)?;
|
||||
// `EXPLAIN QUERY PLAN` yields `(id, parent, notused, detail)`
|
||||
// — the `notused` column at index 2 is dropped.
|
||||
let rows_iter = stmt
|
||||
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
|
||||
Ok(ExplainRow {
|
||||
id: row.get(0)?,
|
||||
parent: row.get(1)?,
|
||||
detail: row.get(3)?,
|
||||
})
|
||||
})
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows_iter {
|
||||
out.push(r.map_err(DbError::from_rusqlite)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Render execution SQL as human-facing display SQL by inlining
|
||||
/// each `?N` placeholder as a standard-SQL literal (ADR-0028
|
||||
/// §3). The scan is quote-aware — a `?N`-shaped run inside a
|
||||
/// double-quoted identifier is left untouched — and the
|
||||
/// trailing `;` is dropped so the result reads as a query.
|
||||
fn inline_params_for_display(sql: &str, params: &[rusqlite::types::Value]) -> String {
|
||||
let mut out = String::with_capacity(sql.len());
|
||||
let mut chars = sql.chars().peekable();
|
||||
let mut in_quote = false;
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' => {
|
||||
in_quote = !in_quote;
|
||||
out.push('"');
|
||||
}
|
||||
'?' if !in_quote && chars.peek().is_some_and(char::is_ascii_digit) => {
|
||||
let mut digits = String::new();
|
||||
while let Some(d) = chars.peek() {
|
||||
if d.is_ascii_digit() {
|
||||
digits.push(*d);
|
||||
chars.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
match digits
|
||||
.parse::<usize>()
|
||||
.ok()
|
||||
.and_then(|n| n.checked_sub(1))
|
||||
.and_then(|idx| params.get(idx))
|
||||
{
|
||||
Some(value) => out.push_str(&sql_literal(value)),
|
||||
// Out of range — leave the placeholder verbatim.
|
||||
None => {
|
||||
out.push('?');
|
||||
out.push_str(&digits);
|
||||
}
|
||||
}
|
||||
}
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
out.trim_end().trim_end_matches(';').trim_end().to_string()
|
||||
}
|
||||
|
||||
/// Render a bound parameter as a standard-SQL literal for the
|
||||
/// display SQL (ADR-0028 §3). Text is single-quoted with
|
||||
/// embedded quotes doubled; a real with no fractional part
|
||||
/// keeps a `.0` so it still reads as a real.
|
||||
fn sql_literal(value: &rusqlite::types::Value) -> String {
|
||||
use rusqlite::types::Value as V;
|
||||
match value {
|
||||
V::Null => "NULL".to_string(),
|
||||
V::Integer(i) => i.to_string(),
|
||||
V::Real(r) => {
|
||||
let s = r.to_string();
|
||||
if s.contains(['.', 'e', 'E']) {
|
||||
s
|
||||
} else {
|
||||
format!("{s}.0")
|
||||
}
|
||||
}
|
||||
V::Text(s) => format!("'{}'", s.replace('\'', "''")),
|
||||
V::Blob(bytes) => {
|
||||
let mut hex = String::with_capacity(bytes.len() * 2 + 3);
|
||||
hex.push_str("x'");
|
||||
for b in bytes {
|
||||
hex.push_str(&format!("{b:02x}"));
|
||||
}
|
||||
hex.push('\'');
|
||||
hex
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_relationships_outbound(
|
||||
conn: &Connection,
|
||||
table: &str,
|
||||
@@ -7885,6 +8119,143 @@ mod tests {
|
||||
assert_eq!(names(&data), vec!["Bob"]);
|
||||
}
|
||||
|
||||
// --- explain / query plans (ADR-0028) -------------------
|
||||
|
||||
/// Parse a non-`explain` query command for use as the inner
|
||||
/// command of `explain_query_plan`.
|
||||
fn parse_inner(dsl: &str) -> Command {
|
||||
crate::dsl::parser::parse_command(dsl).expect("inner command parse")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explain_show_data_returns_a_scan_plan() {
|
||||
let db = db();
|
||||
people_table(&db).await;
|
||||
let plan = db
|
||||
.explain_query_plan(parse_inner("show data People where Active = true"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!plan.rows.is_empty(), "a plan has at least one node");
|
||||
// No index on `Active`, so the filtered query is a full
|
||||
// table scan.
|
||||
assert!(
|
||||
plan.rows.iter().any(|r| r.detail.contains("SCAN")),
|
||||
"expected a SCAN node, got {:?}",
|
||||
plan.rows,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explain_show_data_uses_an_index_when_one_exists() {
|
||||
let db = db();
|
||||
people_table(&db).await;
|
||||
db.add_index(None, "People".to_string(), vec!["Age".to_string()], None)
|
||||
.await
|
||||
.unwrap();
|
||||
let plan = db
|
||||
.explain_query_plan(parse_inner("show data People where Age = 35"))
|
||||
.await
|
||||
.unwrap();
|
||||
// The teaching payoff: the plan flips from a scan to an
|
||||
// index search once an index covers the WHERE column.
|
||||
assert!(
|
||||
plan.rows.iter().any(|r| r.detail.contains("USING INDEX")),
|
||||
"expected an index search, got {:?}",
|
||||
plan.rows,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explain_delete_does_not_remove_any_rows() {
|
||||
let db = db();
|
||||
people_table(&db).await;
|
||||
let plan = db
|
||||
.explain_query_plan(parse_inner("delete from People where Active = true"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!plan.rows.is_empty());
|
||||
// ADR-0028 §1: EXPLAIN QUERY PLAN never executes.
|
||||
let data = db
|
||||
.query_data("People".to_string(), None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(data.rows.len(), 4, "explain delete must not delete");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explain_update_does_not_change_any_data() {
|
||||
let db = db();
|
||||
people_table(&db).await;
|
||||
db.explain_query_plan(parse_inner(
|
||||
"update People set Active = false where Active = true",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let (filter, _) = parse_show("show data People where Active = true");
|
||||
let data = db
|
||||
.query_data("People".to_string(), filter, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
// Alice, Carol, Dave are still active — nothing ran.
|
||||
assert_eq!(data.rows.len(), 3, "explain update must not write");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explain_display_sql_inlines_literals_and_quotes_idents() {
|
||||
let db = db();
|
||||
people_table(&db).await;
|
||||
let plan = db
|
||||
.explain_query_plan(parse_inner("show data People where Name = 'Alice'"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
plan.display_sql.contains("\"People\""),
|
||||
"table identifier should be double-quoted: {}",
|
||||
plan.display_sql,
|
||||
);
|
||||
assert!(
|
||||
plan.display_sql.contains("'Alice'"),
|
||||
"WHERE literal should be inlined: {}",
|
||||
plan.display_sql,
|
||||
);
|
||||
assert!(
|
||||
!plan.display_sql.contains('?'),
|
||||
"display SQL must carry no `?` placeholders: {}",
|
||||
plan.display_sql,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explain_display_sql_shows_the_implicit_order_by_for_limit() {
|
||||
let db = db();
|
||||
people_table(&db).await;
|
||||
let plan = db
|
||||
.explain_query_plan(parse_inner("show data People limit 2"))
|
||||
.await
|
||||
.unwrap();
|
||||
// `limit` adds an implicit ORDER BY the primary key
|
||||
// (ADR-0026 §5) — the display SQL surfaces it.
|
||||
assert!(
|
||||
plan.display_sql.contains("ORDER BY"),
|
||||
"limit implies ORDER BY pk: {}",
|
||||
plan.display_sql,
|
||||
);
|
||||
assert!(
|
||||
plan.display_sql.contains("LIMIT 2"),
|
||||
"limit count inlined: {}",
|
||||
plan.display_sql,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explain_of_a_missing_table_is_an_error() {
|
||||
let db = db();
|
||||
let result = db
|
||||
.explain_query_plan(parse_inner("show data NoSuchTable"))
|
||||
.await;
|
||||
assert!(result.is_err(), "explaining a missing table should fail");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_with_all_rows_affects_everything() {
|
||||
let db = db();
|
||||
|
||||
Reference in New Issue
Block a user