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 crate::app::{OutputKind, OutputLine};
|
||||
use crate::app::{OutputKind, OutputLine, OutputSpan, OutputStyleClass};
|
||||
use crate::db::{ColumnDescription, DataResult, ExplainRow, QueryPlan, TableDescription};
|
||||
use crate::dsl::Type;
|
||||
use crate::mode::Mode;
|
||||
@@ -152,13 +152,45 @@ pub fn render_structure(desc: &TableDescription) -> Vec<String> {
|
||||
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
|
||||
/// (ADR-0028 §3).
|
||||
/// (ADR-0028 §3/§4).
|
||||
///
|
||||
/// 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.
|
||||
/// 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
|
||||
/// returns ready-built `OutputLine`s, because a plan line
|
||||
@@ -167,7 +199,8 @@ pub fn render_structure(desc: &TableDescription) -> Vec<String> {
|
||||
#[must_use]
|
||||
pub fn render_explain_plan(plan: &QueryPlan, mode: Mode) -> Vec<OutputLine> {
|
||||
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
|
||||
// or self-referential `parent` link — every node is drawn
|
||||
// at most once.
|
||||
@@ -196,25 +229,73 @@ fn render_plan_subtree(
|
||||
}
|
||||
let is_last = idx == last_idx;
|
||||
let connector = if is_last { "└─ " } else { "├─ " };
|
||||
out.push(plain_plan_line(
|
||||
format!("{prefix}{connector}{}", row.detail),
|
||||
mode,
|
||||
));
|
||||
out.push(plan_node_line(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,
|
||||
/// Classify `detail` against the taxonomy, returning the byte
|
||||
/// offset + length of the matched marker and its colour class.
|
||||
/// `None` when no marker matches (a neutral node).
|
||||
fn classify_detail(detail: &str) -> Option<(usize, usize, OutputStyleClass)> {
|
||||
PLAN_TAXONOMY
|
||||
.iter()
|
||||
.find_map(|(marker, class)| detail.find(marker).map(|off| (off, marker.len(), *class)))
|
||||
}
|
||||
|
||||
/// 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"));
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn render_explain_plan_survives_a_self_referential_node() {
|
||||
// A malformed plan node that is both a root (`parent ==
|
||||
|
||||
Reference in New Issue
Block a user