Files
rdbms-playground/src/output_render.rs
T
claude@clouddev1 cb8ff8a7c2 feat: ADR-0035 Amendment 1 — drop composite UNIQUE; friendlier drop-column + generic-error wording
F1/F2/F3 from the whole-Phase-4 /runda (handoff-42 §3):

- F3: drop an anonymous composite UNIQUE via a derived, engine-neutral
  name `unique_<cols>` — recomputed live, nothing persisted, reusing the
  existing `DROP CONSTRAINT <name>` grammar (no new syntax/metadata, the
  §4g anonymity decision intact). A name matching more than one UNIQUE is
  refused as ambiguous, never guessed. One undo step. `describe`
  annotates each composite UNIQUE with its name.
- F1: dropping a column a composite UNIQUE covers is refused up-front
  with the derived name + the actionable drop command (was an unhelpful
  generic engine refusal).
- F2: contextless friendly_message() no longer leaks a literal `{table}`
  in the generic hint (new `error.generic.hint_no_table`, selected when
  no table is in context). The table-ful path is unchanged.

Docs: ADR-0035 Amendment 1 + Status + README index + plan
docs/plans/20260526-adr-0035-composite-unique-drop-f1f2f3.md.
Tests: +5 (drop-by-name, ambiguous-refused, one-undo-step, F1 guard,
F2 no-leak) + a describe-render assertion. 1922 pass / 0 fail / 0 skip;
clippy clean.
2026-05-26 16:20:08 +00:00

1110 lines
39 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Pretty-table rendering for the output panel
//! (ADR-0016).
//!
//! Two public entry points: [`render_data_table`] for query
//! / show-data / auto-show outputs, and [`render_structure`]
//! for table-structure listings produced after DDL or
//! `show table`.
//!
//! Both return a `Vec<String>` — one display row per element
//! — to match the existing `OutputLine`-per-line discipline
//! in `app.rs`. Border chars are Unicode box-drawing
//! (UTF-8); no ASCII fallback (ADR-0016 OOS-5).
//!
//! Layout: outer frame + header underline only. No per-row
//! horizontal rules, which keeps typical row counts (550)
//! readable without visual noise.
//!
//! NULL renders as the literal `(null)`; cell newlines, tabs
//! and control characters render as `↵`, `→`, `·`
//! respectively (display-only — underlying data is
//! untouched).
use std::collections::HashSet;
use crate::app::{OutputKind, OutputLine, OutputSpan, OutputStyleClass};
use crate::db::{ColumnDescription, DataResult, ExplainRow, QueryPlan, TableDescription};
use crate::dsl::Type;
use crate::mode::Mode;
const NULL_DISPLAY: &str = "(null)";
/// Render a query / data result as a Vec of display rows.
///
/// Empty result sets yield the header + a `(no rows)` line
/// inside the frame — matches the existing UX hint that "the
/// query ran but there was nothing to show."
#[must_use]
pub fn render_data_table(data: &DataResult) -> Vec<String> {
let header_cells: Vec<String> = data.columns.clone();
let alignments: Vec<Alignment> = data
.column_types
.iter()
.map(|t| alignment_for(*t))
.collect();
let body: Vec<Vec<String>> = if data.rows.is_empty() {
// For empty tables, still render the header band so
// the user sees the column shape, then a single
// `(no rows)` row spanning all columns. We achieve
// the spanning effect with a left-aligned cell in
// the first column and empty cells elsewhere.
let mut row = vec![String::new(); header_cells.len().max(1)];
if let Some(first) = row.first_mut() {
*first = "(no rows)".to_string();
}
vec![row]
} else {
data.rows
.iter()
.map(|r| {
r.iter()
.map(|c| {
c.as_ref()
.map_or_else(|| NULL_DISPLAY.to_string(), |s| sanitize_cell(s))
})
.collect()
})
.collect()
};
render_table(&header_cells, &body, &alignments)
}
/// Render a table-structure listing.
///
/// Produces a header line (`<TableName>`), the schema table
/// itself, and — for a structure that has FK relationships
/// — `References:` / `Referenced by:` blocks below as plain
/// indented text (relationship visualization is its own
/// future ADR per §5 OOS-1).
#[must_use]
pub fn render_structure(desc: &TableDescription) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
out.push(desc.name.clone());
let header_cells = vec![
"Name".to_string(),
"Type".to_string(),
"Constraints".to_string(),
];
let body: Vec<Vec<String>> = desc
.columns
.iter()
.map(|c| {
vec![
c.name.clone(),
type_display(c),
constraints_display(c),
]
})
.collect();
// Type column gets the same numeric/text rule as data
// columns by virtue of consistency, but every entry is
// a keyword string ("text", "serial", …) so left-align
// is correct in every case. Constraints are similarly
// textual.
let alignments = vec![Alignment::Left, Alignment::Left, Alignment::Left];
out.extend(render_table(&header_cells, &body, &alignments));
if !desc.outbound_relationships.is_empty() {
out.push("References:".to_string());
for r in &desc.outbound_relationships {
out.push(format!(
" {}{}.{} ({}, on delete {}, on update {})",
r.local_column,
r.other_table,
r.other_column,
r.name,
r.on_delete,
r.on_update,
));
}
}
if !desc.inbound_relationships.is_empty() {
out.push("Referenced by:".to_string());
for r in &desc.inbound_relationships {
out.push(format!(
" {}.{}{} ({}, on delete {}, on update {})",
r.other_table,
r.other_column,
r.local_column,
r.name,
r.on_delete,
r.on_update,
));
}
}
// Indexes section (ADR-0025), shown only when the table
// carries at least one user-created index. A UNIQUE index is
// marked `[unique]` so a learner can tell a uniqueness-enforcing
// index from a performance-only one (ADR-0035 §4d).
if !desc.indexes.is_empty() {
out.push("Indexes:".to_string());
for index in &desc.indexes {
let unique = if index.unique { " [unique]" } else { "" };
out.push(format!(
" {} ({}){unique}",
index.name,
index.columns.join(", "),
));
}
}
// Table-level constraints (ADR-0035 §4i b): composite `UNIQUE (a, b)`
// and table `CHECK (…)` constraints. Single-column UNIQUE / NOT NULL /
// PK / column-level CHECK already show in the per-column "Constraints"
// column above; this section is the table-level constraints that span
// columns or stand alone. A named CHECK shows its name.
if !desc.unique_constraints.is_empty() || !desc.check_constraints.is_empty() {
out.push("Table constraints:".to_string());
for cols in &desc.unique_constraints {
// Annotate with the derived, addressable name (ADR-0035
// Amendment 1) so the user can `drop constraint <name>`.
out.push(format!(
" {}: unique ({})",
crate::db::unique_constraint_name(cols),
cols.join(", ")
));
}
for chk in &desc.check_constraints {
match &chk.name {
Some(name) => out.push(format!(" check {name} ({})", chk.expr)),
None => out.push(format!(" check ({})", chk.expr)),
}
}
}
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/§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 — 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
/// carries per-span styling (ADR-0028 §5). Each line is
/// stamped with `mode` for the submission-mode tag.
#[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);
// 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.
let mut emitted: HashSet<i64> = HashSet::new();
render_plan_subtree(&plan.rows, 0, "", &mut out, &mut emitted, mode);
out
}
/// Append the subtree rooted at `parent` (`0` = top level) to
/// `out`, drawing box-drawing connectors. `prefix` is the
/// indent accumulated from ancestor levels.
fn render_plan_subtree(
rows: &[ExplainRow],
parent: i64,
prefix: &str,
out: &mut Vec<OutputLine>,
emitted: &mut HashSet<i64>,
mode: Mode,
) {
let children: Vec<&ExplainRow> =
rows.iter().filter(|r| r.parent == parent).collect();
let last_idx = children.len().saturating_sub(1);
for (idx, row) in children.iter().enumerate() {
if !emitted.insert(row.id) {
continue;
}
let is_last = idx == last_idx;
let connector = if is_last { "└─ " } else { "├─ " };
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);
}
}
/// 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,
});
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Alignment {
Left,
Right,
}
/// Per-column alignment hint for [`render_diagnostic_table`].
#[must_use]
pub const fn numeric_alignment_for(ty: Type) -> Alignment {
alignment_for(Some(ty))
}
/// Render an arbitrary bordered table from headers + rows +
/// per-column alignments. Used for change-column diagnostic
/// output (ADR-0017 §7) — anything tabular goes through the
/// pretty-table renderer.
///
/// Cell contents are sanitised the same way as
/// [`render_data_table`]: newlines and control characters
/// become visible markers (display-only).
#[must_use]
pub fn render_diagnostic_table(
headers: &[String],
rows: &[Vec<String>],
alignments: &[Alignment],
) -> Vec<String> {
let body: Vec<Vec<String>> = rows
.iter()
.map(|r| r.iter().map(|c| sanitize_cell(c)).collect())
.collect();
render_table(headers, &body, alignments)
}
/// Map a user-facing type to its column alignment per
/// ADR-0016 §2. `None` (foreign-attached, no metadata)
/// falls back to Left.
const fn alignment_for(ty: Option<Type>) -> Alignment {
match ty {
Some(Type::Int | Type::Real | Type::Decimal | Type::Serial) => Alignment::Right,
Some(Type::Text)
| Some(Type::Bool)
| Some(Type::Date)
| Some(Type::DateTime)
| Some(Type::Blob)
| Some(Type::ShortId)
| None => Alignment::Left,
}
}
fn type_display(c: &ColumnDescription) -> String {
c.user_type
.map_or_else(|| c.sqlite_type.to_lowercase(), |t| t.keyword().to_string())
}
fn constraints_display(c: &ColumnDescription) -> String {
let mut parts: Vec<String> = Vec::new();
if c.primary_key {
parts.push("PK".to_string());
}
if c.notnull {
parts.push("NOT NULL".to_string());
}
// ADR-0029: a PK column's implicit uniqueness is already
// conveyed by `PK`; `unique` is only set for non-PK columns.
if c.unique {
parts.push("UNIQUE".to_string());
}
if let Some(default) = &c.default {
parts.push(format!("DEFAULT {default}"));
}
if let Some(check) = &c.check {
parts.push(format!("CHECK ({check})"));
}
parts.join(", ")
}
/// Replace newlines / tabs / other control characters with
/// visible display markers per ADR-0016 §3. Display-only;
/// underlying data is untouched.
fn sanitize_cell(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'\n' => out.push('↵'),
'\t' => out.push('→'),
c if (c as u32) < 0x20 => out.push('·'),
other => out.push(other),
}
}
out
}
/// Width in Unicode codepoints. Matches existing
/// width-counting throughout the codebase; deferring true
/// Unicode-Width handling to a later pass.
fn cell_width(s: &str) -> usize {
s.chars().count()
}
/// Render a single bordered table given header cells, body
/// rows, and per-column alignment. Outer frame +
/// header-underline only.
fn render_table(
headers: &[String],
body: &[Vec<String>],
alignments: &[Alignment],
) -> Vec<String> {
debug_assert_eq!(headers.len(), alignments.len());
// Compute column widths: max(header, all body cells).
// Empty headers + empty body produces an empty table,
// which we still want to render as a single horizontal
// line — easier to reason about than a missing one.
let column_count = headers.len();
let mut widths: Vec<usize> = headers.iter().map(|s| cell_width(s)).collect();
for row in body {
for (i, cell) in row.iter().enumerate() {
if i < widths.len() {
let w = cell_width(cell);
if w > widths[i] {
widths[i] = w;
}
}
}
}
let mut out: Vec<String> = Vec::with_capacity(body.len() + 3);
out.push(border_row(&widths, BorderRow::Top));
out.push(content_row(headers, &widths, alignments));
out.push(border_row(&widths, BorderRow::HeaderUnderline));
if column_count == 0 {
// Nothing more to render; the bottom border closes
// an empty box. Unusual but well-defined.
} else {
for row in body {
out.push(content_row(row, &widths, alignments));
}
}
out.push(border_row(&widths, BorderRow::Bottom));
out
}
#[derive(Debug, Clone, Copy)]
enum BorderRow {
Top,
HeaderUnderline,
Bottom,
}
fn border_row(widths: &[usize], kind: BorderRow) -> String {
let (left, mid, right) = match kind {
BorderRow::Top => ('┌', '┬', '┐'),
BorderRow::HeaderUnderline => ('├', '┼', '┤'),
BorderRow::Bottom => ('└', '┴', '┘'),
};
let mut s = String::new();
s.push(left);
if widths.is_empty() {
s.push(right);
return s;
}
for (i, w) in widths.iter().enumerate() {
if i > 0 {
s.push(mid);
}
// One space of padding on each side of the cell, so
// a width-w cell occupies w + 2 box columns.
for _ in 0..(w + 2) {
s.push('─');
}
}
s.push(right);
s
}
fn content_row(cells: &[String], widths: &[usize], alignments: &[Alignment]) -> String {
let mut s = String::new();
s.push('│');
for (i, w) in widths.iter().enumerate() {
let cell = cells.get(i).cloned().unwrap_or_default();
let align = alignments.get(i).copied().unwrap_or(Alignment::Left);
s.push(' ');
let pad_total = w.saturating_sub(cell_width(&cell));
match align {
Alignment::Left => {
s.push_str(&cell);
for _ in 0..pad_total {
s.push(' ');
}
}
Alignment::Right => {
for _ in 0..pad_total {
s.push(' ');
}
s.push_str(&cell);
}
}
s.push(' ');
s.push('│');
}
s
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::{ColumnDescription, IndexInfo, RelationshipEnd};
use crate::dsl::ReferentialAction;
use insta::assert_snapshot;
fn col(name: &str, ty: Type, pk: bool, notnull: bool) -> ColumnDescription {
ColumnDescription {
name: name.to_string(),
user_type: Some(ty),
sqlite_type: match ty {
Type::Int | Type::Serial | Type::Bool => "INTEGER".to_string(),
Type::Real => "REAL".to_string(),
Type::Blob => "BLOB".to_string(),
_ => "TEXT".to_string(),
},
notnull,
primary_key: pk,
unique: false,
default: None,
check: None,
}
}
// --- Width / alignment helpers ----------------------
#[test]
fn alignment_for_numeric_types_is_right() {
for ty in [Type::Int, Type::Real, Type::Decimal, Type::Serial] {
assert_eq!(alignment_for(Some(ty)), Alignment::Right, "{ty:?}");
}
}
#[test]
fn alignment_for_other_types_is_left() {
for ty in [
Type::Text,
Type::Bool,
Type::Date,
Type::DateTime,
Type::Blob,
Type::ShortId,
] {
assert_eq!(alignment_for(Some(ty)), Alignment::Left, "{ty:?}");
}
}
#[test]
fn alignment_for_unknown_type_is_left() {
assert_eq!(alignment_for(None), Alignment::Left);
}
// --- sanitize_cell ----------------------------------
#[test]
fn sanitize_replaces_newline_with_return_arrow() {
assert_eq!(sanitize_cell("a\nb"), "a↵b");
}
#[test]
fn sanitize_replaces_tab_with_arrow() {
assert_eq!(sanitize_cell("a\tb"), "a→b");
}
#[test]
fn sanitize_replaces_other_control_chars_with_dot() {
// \x07 is BEL; should become a middle dot.
assert_eq!(sanitize_cell("a\x07b"), "a·b");
}
#[test]
fn sanitize_passes_through_normal_text() {
assert_eq!(sanitize_cell("Alice & Bob"), "Alice & Bob");
}
// --- cell_width -------------------------------------
#[test]
fn cell_width_counts_codepoints_not_bytes() {
// 'é' is 2 bytes in UTF-8 but counts as 1 codepoint.
assert_eq!(cell_width("héllo"), 5);
assert_eq!("héllo".len(), 6);
}
// --- render_data_table snapshots --------------------
#[test]
fn render_data_table_basic_shape() {
let data = DataResult {
table_name: "Customers".to_string(),
columns: vec!["id".to_string(), "Name".to_string(), "Email".to_string()],
column_types: vec![
Some(Type::Serial),
Some(Type::Text),
Some(Type::Text),
],
rows: vec![
vec![
Some("1".to_string()),
Some("Alice".to_string()),
Some("a@example.io".to_string()),
],
vec![
Some("2".to_string()),
Some("Bob".to_string()),
Some("b@example.io".to_string()),
],
],
};
assert_snapshot!(render_data_table(&data).join("\n"));
}
#[test]
fn render_data_table_empty_rows_shows_no_rows_marker() {
let data = DataResult {
table_name: "Customers".to_string(),
columns: vec!["id".to_string(), "Name".to_string()],
column_types: vec![Some(Type::Serial), Some(Type::Text)],
rows: Vec::new(),
};
let out = render_data_table(&data).join("\n");
assert!(out.contains("(no rows)"), "got:\n{out}");
assert_snapshot!(out);
}
#[test]
fn render_data_table_nulls_render_as_null_marker() {
let data = DataResult {
table_name: "T".to_string(),
columns: vec!["a".to_string(), "b".to_string()],
column_types: vec![Some(Type::Int), Some(Type::Text)],
rows: vec![
vec![Some("1".to_string()), None],
vec![None, Some("hi".to_string())],
],
};
let out = render_data_table(&data).join("\n");
assert!(out.contains("(null)"), "got:\n{out}");
}
#[test]
fn render_data_table_right_aligns_numerics() {
// A numeric column with mixed-width values should
// right-align so the digits' ones place stacks.
// Single-char header keeps column width = max body
// width = 3 (i.e. "999"), making the assertions
// explicit.
let data = DataResult {
table_name: "T".to_string(),
columns: vec!["n".to_string()],
column_types: vec![Some(Type::Int)],
rows: vec![
vec![Some("1".to_string())],
vec![Some("42".to_string())],
vec![Some("999".to_string())],
],
};
let out = render_data_table(&data).join("\n");
// The narrowest value ("1") must have leading
// padding so its rightmost digit lines up with "9"
// and "2" in the wider rows.
assert!(
out.contains("│ 1 │"),
"expected right-aligned `1`:\n{out}",
);
assert!(
out.contains("│ 42 │"),
"expected right-aligned `42`:\n{out}",
);
assert!(
out.contains("│ 999 │"),
"expected right-aligned `999`:\n{out}",
);
}
#[test]
fn render_data_table_left_aligns_text() {
let data = DataResult {
table_name: "T".to_string(),
columns: vec!["Name".to_string()],
column_types: vec![Some(Type::Text)],
rows: vec![
vec![Some("Alice".to_string())],
vec![Some("Bo".to_string())],
],
};
let out = render_data_table(&data).join("\n");
assert!(
out.contains("│ Alice │"),
"expected left-aligned `Alice`:\n{out}",
);
assert!(
out.contains("│ Bo │"),
"expected left-aligned `Bo` with trailing pad:\n{out}",
);
}
#[test]
fn render_data_table_handles_newline_in_cell() {
let data = DataResult {
table_name: "T".to_string(),
columns: vec!["note".to_string()],
column_types: vec![Some(Type::Text)],
rows: vec![vec![Some("Hello\nWorld".to_string())]],
};
let out = render_data_table(&data).join("\n");
assert!(out.contains("Hello↵World"), "got:\n{out}");
}
// --- render_structure snapshots ---------------------
#[test]
fn render_structure_basic() {
let desc = TableDescription {
name: "Customers".to_string(),
columns: vec![
col("id", Type::Serial, true, false),
col("Name", Type::Text, false, true),
col("Email", Type::Text, false, false),
],
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
indexes: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
assert_snapshot!(render_structure(&desc).join("\n"));
}
#[test]
fn render_structure_with_relationships() {
let desc = TableDescription {
name: "Customers".to_string(),
columns: vec![col("id", Type::Serial, true, false)],
outbound_relationships: Vec::new(),
inbound_relationships: vec![RelationshipEnd {
name: "cust_orders".to_string(),
other_table: "Orders".to_string(),
other_column: "cust_id".to_string(),
local_column: "id".to_string(),
on_delete: ReferentialAction::Cascade,
on_update: ReferentialAction::NoAction,
}],
indexes: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
let out = render_structure(&desc).join("\n");
assert!(
out.contains("Referenced by:"),
"expected inbound relationship section:\n{out}",
);
assert!(
out.contains("Orders.cust_id → id"),
"expected inbound relationship line:\n{out}",
);
assert_snapshot!(out);
}
#[test]
fn render_structure_pk_and_notnull_render_in_constraints() {
let desc = TableDescription {
name: "T".to_string(),
columns: vec![
col("id", Type::Serial, true, false),
col("name", Type::Text, false, true),
col("nick", Type::Text, false, false),
],
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
indexes: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
let out = render_structure(&desc).join("\n");
// PK appears for id, NOT NULL for name, blank for nick.
assert!(out.contains("│ id │ serial │ PK"), "got:\n{out}");
assert!(out.contains("│ name │ text │ NOT NULL"), "got:\n{out}");
}
#[test]
fn render_structure_shows_indexes_section() {
let desc = TableDescription {
name: "Customers".to_string(),
columns: vec![
col("id", Type::Serial, true, false),
col("Email", Type::Text, false, false),
],
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
indexes: vec![IndexInfo {
name: "idx_email".to_string(),
columns: vec!["Email".to_string()],
unique: false,
}],
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
let out = render_structure(&desc).join("\n");
assert!(out.contains("Indexes:"), "got:\n{out}");
assert!(out.contains("idx_email (Email)"), "got:\n{out}");
// A plain index carries no uniqueness marker.
assert!(!out.contains("[unique]"), "plain index unmarked; got:\n{out}");
}
#[test]
fn render_structure_marks_a_unique_index() {
// ADR-0035 §4d: a UNIQUE index is marked `[unique]` so a learner
// can tell it from a performance-only index.
let desc = TableDescription {
name: "Customers".to_string(),
columns: vec![
col("id", Type::Serial, true, false),
col("Email", Type::Text, false, false),
],
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
indexes: vec![IndexInfo {
name: "uidx_email".to_string(),
columns: vec!["Email".to_string()],
unique: true,
}],
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
let out = render_structure(&desc).join("\n");
assert!(out.contains("uidx_email (Email) [unique]"), "got:\n{out}");
}
#[test]
fn render_structure_shows_table_level_constraints() {
// ADR-0035 §4i (b): composite UNIQUE and table-level CHECK
// (named + unnamed) render in a "Table constraints:" section,
// distinct from the per-column "Constraints" column.
let desc = TableDescription {
name: "T".to_string(),
columns: vec![
col("a", Type::Int, true, false),
col("b", Type::Int, false, false),
],
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
indexes: Vec::new(),
unique_constraints: vec![vec!["a".to_string(), "b".to_string()]],
check_constraints: vec![
crate::persistence::TableCheck { name: None, expr: "a < b".to_string() },
crate::persistence::TableCheck {
name: Some("a_lt_b".to_string()),
expr: "a <> b".to_string(),
},
],
};
let out = render_structure(&desc).join("\n");
assert!(out.contains("Table constraints:"), "got:\n{out}");
assert!(out.contains("unique (a, b)"), "got:\n{out}");
// The composite UNIQUE shows its derived, addressable name
// (ADR-0035 Amendment 1) so the user can `drop constraint <name>`.
assert!(out.contains("unique_a_b: unique (a, b)"), "got:\n{out}");
assert!(out.contains("check (a < b)"), "unnamed check; got:\n{out}");
assert!(out.contains("check a_lt_b (a <> b)"), "named check shows its name; got:\n{out}");
}
#[test]
fn render_structure_omits_indexes_section_when_none() {
let desc = TableDescription {
name: "T".to_string(),
columns: vec![col("id", Type::Serial, true, false)],
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
indexes: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
let out = render_structure(&desc).join("\n");
assert!(!out.contains("Indexes:"), "got:\n{out}");
}
// --- render_explain_plan (ADR-0028 §3) --------------
#[test]
fn render_explain_plan_puts_display_sql_first() {
let plan = QueryPlan {
display_sql: "SELECT \"id\" FROM \"T\"".to_string(),
rows: vec![ExplainRow {
id: 2,
parent: 0,
detail: "SCAN T".to_string(),
}],
};
let lines = render_explain_plan(&plan, Mode::Simple);
assert_eq!(lines[0].text, "SELECT \"id\" FROM \"T\"");
assert!(lines[1].text.contains("SCAN T"), "got {:?}", lines[1].text);
assert!(lines[1].text.contains('└'), "last node uses └─");
}
#[test]
fn render_explain_plan_nests_children_under_parents() {
let plan = QueryPlan {
display_sql: "SELECT 1".to_string(),
rows: vec![
ExplainRow { id: 1, parent: 0, detail: "root".to_string() },
ExplainRow { id: 2, parent: 1, detail: "child-a".to_string() },
ExplainRow { id: 3, parent: 1, detail: "child-b".to_string() },
],
};
let lines = render_explain_plan(&plan, Mode::Simple);
// display SQL + 3 plan nodes.
assert_eq!(lines.len(), 4);
assert!(lines[1].text.contains("root"));
assert!(lines[2].text.contains("├─ child-a"), "got {:?}", lines[2].text);
assert!(lines[3].text.contains("└─ child-b"), "got {:?}", lines[3].text);
// The single root uses `└─`; its children are indented
// by three spaces (no `│` spine, the root being last).
assert!(lines[1].text.starts_with("└─ root"));
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 ==
// 0`) and its own parent (`id == 0`) must not loop.
let plan = QueryPlan {
display_sql: String::new(),
rows: vec![ExplainRow {
id: 0,
parent: 0,
detail: "self-rooted".to_string(),
}],
};
let lines = render_explain_plan(&plan, Mode::Simple);
// display-SQL line + the node, drawn exactly once.
assert_eq!(lines.len(), 2);
}
#[test]
fn render_structure_falls_back_to_sqlite_type_when_user_type_missing() {
let mut desc = TableDescription {
name: "T".to_string(),
columns: vec![ColumnDescription {
name: "x".to_string(),
user_type: None,
sqlite_type: "INTEGER".to_string(),
notnull: false,
primary_key: false,
unique: false,
default: None,
check: None,
}],
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
indexes: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
let out = render_structure(&desc).join("\n");
// The lowercase form of the SQLite type should appear.
assert!(out.contains("integer"), "got:\n{out}");
// And the name column should still align correctly with no user_type.
desc.columns[0].user_type = Some(Type::Int);
let with_type = render_structure(&desc).join("\n");
assert!(with_type.contains("int"), "got:\n{with_type}");
}
}