explain: styled plan tree + annotation taxonomy (ADR-0028 step 4)
`render_explain_plan` now classifies each plan node and colours its category-bearing keywords through the styled-runs mechanism. - `PLAN_TAXONOMY`: a substring-pattern table mapping the engine's plan vocabulary to four semantic classes — full scan / temp B-tree -> Expensive, index search / covering index / PK lookup -> Efficient, automatic index -> AutomaticIndex. An unrecognised detail renders neutral, since the engine's plan vocabulary may grow. - Only the matched keyword run carries the category colour; connectors, prefixes and table / index names stay neutral (ADR-0028 §6). The display-SQL line is wholly neutral. - An automatic-index node also gets the distinct "← add an index?" advice tag, so it reads as guidance, not merely "this is slow". 1158 tests pass (+7); clippy clean.
This commit is contained in:
+211
-17
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use crate::app::{OutputKind, OutputLine};
|
use crate::app::{OutputKind, OutputLine, OutputSpan, OutputStyleClass};
|
||||||
use crate::db::{ColumnDescription, DataResult, ExplainRow, QueryPlan, TableDescription};
|
use crate::db::{ColumnDescription, DataResult, ExplainRow, QueryPlan, TableDescription};
|
||||||
use crate::dsl::Type;
|
use crate::dsl::Type;
|
||||||
use crate::mode::Mode;
|
use crate::mode::Mode;
|
||||||
@@ -152,13 +152,45 @@ pub fn render_structure(desc: &TableDescription) -> Vec<String> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The plan annotation taxonomy (ADR-0028 §4).
|
||||||
|
///
|
||||||
|
/// A `detail` string is classified by the **first** entry
|
||||||
|
/// whose marker substring it contains; the marker's byte range
|
||||||
|
/// is also the run that carries the category colour (ADR-0028
|
||||||
|
/// §6 — "only the category-bearing keywords"). Order matters:
|
||||||
|
/// the automatic-index markers come before the plain-index and
|
||||||
|
/// `SCAN` ones so a `SEARCH … USING AUTOMATIC … INDEX` is not
|
||||||
|
/// mis-read as a plain index search or a full scan; `SCAN`
|
||||||
|
/// comes last so a covering-index scan (`SCAN … USING INDEX …`)
|
||||||
|
/// classifies on its index, not on the `SCAN`.
|
||||||
|
///
|
||||||
|
/// A `detail` matching no marker renders neutral — the engine's
|
||||||
|
/// plan vocabulary may grow (ADR-0028 §4).
|
||||||
|
const PLAN_TAXONOMY: &[(&str, OutputStyleClass)] = &[
|
||||||
|
("USING AUTOMATIC COVERING INDEX", OutputStyleClass::AutomaticIndex),
|
||||||
|
("USING AUTOMATIC INDEX", OutputStyleClass::AutomaticIndex),
|
||||||
|
("USING COVERING INDEX", OutputStyleClass::Efficient),
|
||||||
|
("USING INTEGER PRIMARY KEY", OutputStyleClass::Efficient),
|
||||||
|
("USING PRIMARY KEY", OutputStyleClass::Efficient),
|
||||||
|
("USING INDEX", OutputStyleClass::Efficient),
|
||||||
|
("USE TEMP B-TREE", OutputStyleClass::Expensive),
|
||||||
|
("SCAN", OutputStyleClass::Expensive),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The short "you should add an index here" tag appended to an
|
||||||
|
/// automatic-index plan node (ADR-0028 §6 — the distinct
|
||||||
|
/// marker that makes it read as advice, not merely "slow").
|
||||||
|
const AUTO_INDEX_TAG: &str = " ← add an index?";
|
||||||
|
|
||||||
/// Render a captured query plan as a box-drawing tree
|
/// Render a captured query plan as a box-drawing tree
|
||||||
/// (ADR-0028 §3).
|
/// (ADR-0028 §3/§4).
|
||||||
///
|
///
|
||||||
/// The standard-SQL display form of the explained statement is
|
/// The standard-SQL display form of the explained statement is
|
||||||
/// shown first; the plan tree follows, built from each row's
|
/// shown first; the plan tree follows, built from each row's
|
||||||
/// `id` / `parent` links. Node text is the engine's `detail`
|
/// `id` / `parent` links. Node text is the engine's `detail`
|
||||||
/// string **verbatim** — nothing is reworded.
|
/// string **verbatim** — nothing is reworded — but each
|
||||||
|
/// node's category-bearing keywords carry a semantic colour
|
||||||
|
/// from the annotation taxonomy.
|
||||||
///
|
///
|
||||||
/// Unlike the `Vec<String>`-returning renderers above this
|
/// Unlike the `Vec<String>`-returning renderers above this
|
||||||
/// returns ready-built `OutputLine`s, because a plan line
|
/// returns ready-built `OutputLine`s, because a plan line
|
||||||
@@ -167,7 +199,8 @@ pub fn render_structure(desc: &TableDescription) -> Vec<String> {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn render_explain_plan(plan: &QueryPlan, mode: Mode) -> Vec<OutputLine> {
|
pub fn render_explain_plan(plan: &QueryPlan, mode: Mode) -> Vec<OutputLine> {
|
||||||
let mut out: Vec<OutputLine> = Vec::with_capacity(plan.rows.len() + 1);
|
let mut out: Vec<OutputLine> = Vec::with_capacity(plan.rows.len() + 1);
|
||||||
out.push(plain_plan_line(plan.display_sql.clone(), mode));
|
// The display SQL is wholly neutral — no category keywords.
|
||||||
|
out.push(neutral_plan_line(plan.display_sql.clone(), mode));
|
||||||
// `emitted` guards against a malformed plan with a cyclic
|
// `emitted` guards against a malformed plan with a cyclic
|
||||||
// or self-referential `parent` link — every node is drawn
|
// or self-referential `parent` link — every node is drawn
|
||||||
// at most once.
|
// at most once.
|
||||||
@@ -196,25 +229,73 @@ fn render_plan_subtree(
|
|||||||
}
|
}
|
||||||
let is_last = idx == last_idx;
|
let is_last = idx == last_idx;
|
||||||
let connector = if is_last { "└─ " } else { "├─ " };
|
let connector = if is_last { "└─ " } else { "├─ " };
|
||||||
out.push(plain_plan_line(
|
out.push(plan_node_line(prefix, connector, &row.detail, mode));
|
||||||
format!("{prefix}{connector}{}", row.detail),
|
|
||||||
mode,
|
|
||||||
));
|
|
||||||
let child_prefix =
|
let child_prefix =
|
||||||
format!("{prefix}{}", if is_last { " " } else { "│ " });
|
format!("{prefix}{}", if is_last { " " } else { "│ " });
|
||||||
render_plan_subtree(rows, row.id, &child_prefix, out, emitted, mode);
|
render_plan_subtree(rows, row.id, &child_prefix, out, emitted, mode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A plain (unstyled) plan output line. ADR-0028 step 4 swaps
|
/// Classify `detail` against the taxonomy, returning the byte
|
||||||
/// the tree lines for span-styled ones; the display-SQL line
|
/// offset + length of the matched marker and its colour class.
|
||||||
/// stays plain.
|
/// `None` when no marker matches (a neutral node).
|
||||||
const fn plain_plan_line(text: String, mode: Mode) -> OutputLine {
|
fn classify_detail(detail: &str) -> Option<(usize, usize, OutputStyleClass)> {
|
||||||
OutputLine {
|
PLAN_TAXONOMY
|
||||||
text,
|
.iter()
|
||||||
kind: OutputKind::System,
|
.find_map(|(marker, class)| detail.find(marker).map(|off| (off, marker.len(), *class)))
|
||||||
mode_at_submission: mode,
|
}
|
||||||
styled_runs: None,
|
|
||||||
|
/// Build one plan-tree node line: `{prefix}{connector}{detail}`
|
||||||
|
/// with the connectors / prefix / table & index names neutral
|
||||||
|
/// and only the category-bearing keyword run coloured
|
||||||
|
/// (ADR-0028 §6). An automatic-index node also gets the
|
||||||
|
/// "add an index?" advice tag.
|
||||||
|
fn plan_node_line(prefix: &str, connector: &str, detail: &str, mode: Mode) -> OutputLine {
|
||||||
|
let mut text = format!("{prefix}{connector}{detail}");
|
||||||
|
let detail_start = prefix.len() + connector.len();
|
||||||
|
let mut runs: Vec<OutputSpan> = Vec::new();
|
||||||
|
match classify_detail(detail) {
|
||||||
|
Some((offset, len, class)) => {
|
||||||
|
let marker_start = detail_start + offset;
|
||||||
|
let marker_end = marker_start + len;
|
||||||
|
push_neutral(&mut runs, 0, marker_start);
|
||||||
|
runs.push(OutputSpan {
|
||||||
|
byte_range: (marker_start, marker_end),
|
||||||
|
class,
|
||||||
|
});
|
||||||
|
push_neutral(&mut runs, marker_end, text.len());
|
||||||
|
if class == OutputStyleClass::AutomaticIndex {
|
||||||
|
let tag_start = text.len();
|
||||||
|
text.push_str(AUTO_INDEX_TAG);
|
||||||
|
runs.push(OutputSpan {
|
||||||
|
byte_range: (tag_start, text.len()),
|
||||||
|
class,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => push_neutral(&mut runs, 0, text.len()),
|
||||||
|
}
|
||||||
|
OutputLine::styled(text, OutputKind::System, mode, runs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A wholly-neutral plan line — used for the display-SQL line,
|
||||||
|
/// which carries no category keywords. Styled (rather than
|
||||||
|
/// plain) so it renders in the neutral foreground colour
|
||||||
|
/// instead of the whole-line `System` styling (ADR-0028 §6).
|
||||||
|
fn neutral_plan_line(text: String, mode: Mode) -> OutputLine {
|
||||||
|
let mut runs = Vec::new();
|
||||||
|
push_neutral(&mut runs, 0, text.len());
|
||||||
|
OutputLine::styled(text, OutputKind::System, mode, runs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push a `Neutral` span covering `[start, end)`, skipping the
|
||||||
|
/// empty case.
|
||||||
|
fn push_neutral(runs: &mut Vec<OutputSpan>, start: usize, end: usize) {
|
||||||
|
if end > start {
|
||||||
|
runs.push(OutputSpan {
|
||||||
|
byte_range: (start, end),
|
||||||
|
class: OutputStyleClass::Neutral,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,6 +838,119 @@ mod tests {
|
|||||||
assert!(lines[2].text.starts_with(" ├─ child-a"));
|
assert!(lines[2].text.starts_with(" ├─ child-a"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The semantic class of the styled run covering the first
|
||||||
|
/// occurrence of `needle` in `line`'s text.
|
||||||
|
fn span_class_for(line: &OutputLine, needle: &str) -> OutputStyleClass {
|
||||||
|
let runs = line.styled_runs.as_ref().expect("line should be styled");
|
||||||
|
let at = line.text.find(needle).expect("needle present in text");
|
||||||
|
runs.iter()
|
||||||
|
.find(|s| s.byte_range.0 <= at && at < s.byte_range.1)
|
||||||
|
.map(|s| s.class)
|
||||||
|
.expect("a styled run covers the needle")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn one_node_plan(detail: &str) -> QueryPlan {
|
||||||
|
QueryPlan {
|
||||||
|
display_sql: "SELECT 1".to_string(),
|
||||||
|
rows: vec![ExplainRow {
|
||||||
|
id: 1,
|
||||||
|
parent: 0,
|
||||||
|
detail: detail.to_string(),
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_explain_plan_colours_a_full_scan_expensive() {
|
||||||
|
let plan = one_node_plan("SCAN Customers");
|
||||||
|
let lines = render_explain_plan(&plan, Mode::Simple);
|
||||||
|
assert_eq!(span_class_for(&lines[1], "SCAN"), OutputStyleClass::Expensive);
|
||||||
|
// The table name stays neutral (ADR-0028 §6).
|
||||||
|
assert_eq!(
|
||||||
|
span_class_for(&lines[1], "Customers"),
|
||||||
|
OutputStyleClass::Neutral,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_explain_plan_colours_an_index_search_efficient() {
|
||||||
|
let plan = one_node_plan("SEARCH Customers USING INDEX Customers_Email_idx (Email=?)");
|
||||||
|
let lines = render_explain_plan(&plan, Mode::Simple);
|
||||||
|
assert_eq!(
|
||||||
|
span_class_for(&lines[1], "USING INDEX"),
|
||||||
|
OutputStyleClass::Efficient,
|
||||||
|
);
|
||||||
|
// The index name and the connector stay neutral.
|
||||||
|
assert_eq!(
|
||||||
|
span_class_for(&lines[1], "Customers_Email_idx"),
|
||||||
|
OutputStyleClass::Neutral,
|
||||||
|
);
|
||||||
|
assert_eq!(span_class_for(&lines[1], "└─"), OutputStyleClass::Neutral);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_explain_plan_flags_an_automatic_index() {
|
||||||
|
let plan =
|
||||||
|
one_node_plan("SEARCH Orders USING AUTOMATIC COVERING INDEX (CustId=?)");
|
||||||
|
let lines = render_explain_plan(&plan, Mode::Simple);
|
||||||
|
assert_eq!(
|
||||||
|
span_class_for(&lines[1], "USING AUTOMATIC COVERING INDEX"),
|
||||||
|
OutputStyleClass::AutomaticIndex,
|
||||||
|
);
|
||||||
|
// ADR-0028 §6: the distinct "add an index" advice tag.
|
||||||
|
assert!(
|
||||||
|
lines[1].text.ends_with("← add an index?"),
|
||||||
|
"got {:?}",
|
||||||
|
lines[1].text,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
span_class_for(&lines[1], "add an index"),
|
||||||
|
OutputStyleClass::AutomaticIndex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_explain_plan_temp_btree_is_expensive() {
|
||||||
|
let plan = one_node_plan("USE TEMP B-TREE FOR ORDER BY");
|
||||||
|
let lines = render_explain_plan(&plan, Mode::Simple);
|
||||||
|
assert_eq!(
|
||||||
|
span_class_for(&lines[1], "USE TEMP B-TREE"),
|
||||||
|
OutputStyleClass::Expensive,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_explain_plan_covering_index_scan_classifies_on_the_index() {
|
||||||
|
// A `SCAN … USING INDEX …` is a covering-index scan —
|
||||||
|
// it must read as efficient, not as a full scan.
|
||||||
|
let plan = one_node_plan("SCAN Customers USING COVERING INDEX idx");
|
||||||
|
let lines = render_explain_plan(&plan, Mode::Simple);
|
||||||
|
assert_eq!(
|
||||||
|
span_class_for(&lines[1], "USING COVERING INDEX"),
|
||||||
|
OutputStyleClass::Efficient,
|
||||||
|
);
|
||||||
|
assert_eq!(span_class_for(&lines[1], "SCAN"), OutputStyleClass::Neutral);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_explain_plan_unrecognised_detail_is_neutral() {
|
||||||
|
let plan = one_node_plan("CO-ROUTINE 0x1");
|
||||||
|
let lines = render_explain_plan(&plan, Mode::Simple);
|
||||||
|
let runs = lines[1].styled_runs.as_ref().unwrap();
|
||||||
|
assert!(
|
||||||
|
runs.iter().all(|s| s.class == OutputStyleClass::Neutral),
|
||||||
|
"an unrecognised detail must render wholly neutral: {runs:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_explain_plan_display_sql_line_is_neutral() {
|
||||||
|
let plan = one_node_plan("SCAN T");
|
||||||
|
let lines = render_explain_plan(&plan, Mode::Simple);
|
||||||
|
let runs = lines[0].styled_runs.as_ref().unwrap();
|
||||||
|
assert!(runs.iter().all(|s| s.class == OutputStyleClass::Neutral));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn render_explain_plan_survives_a_self_referential_node() {
|
fn render_explain_plan_survives_a_self_referential_node() {
|
||||||
// A malformed plan node that is both a root (`parent ==
|
// A malformed plan node that is both a root (`parent ==
|
||||||
|
|||||||
Reference in New Issue
Block a user