a0ee32393f
show table <T> and add/drop relationship echoes now render the focal structure box plus a Relationships section of compact stacked connector diagrams (child-left/parent-right, n…1, actions); incidental DDL echoes keep the prose References:/Referenced by: form. Selected by command in handle_dsl_success via the "relationship-relevant" reach. - output_render.rs: render_structure refactored into section helpers (box/prose/index/constraint), byte-identical output; new render_structure_with_diagrams + compact-box rendering - app.rs: handle_dsl_success routes ShowTable/Add/DropRelationship to the diagram path, others to prose - fixes: eager widths[1] index on compact (1-col) boxes; body-cell padding under title-widening (name wider than columns) Tests: unit + snapshot + integration; add-relationship echo test updated to the diagram form. Full suite 2203 pass / 0 fail / 1 ignored; clippy clean. V1 still [/] (compound routing + self-ref remain).
1763 lines
61 KiB
Rust
1763 lines
61 KiB
Rust
//! 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 (5–50)
|
||
//! 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).
|
||
/// Display a relationship-endpoint column list (ADR-0043): the bare
|
||
/// column for a single-column FK, `(a, b)` for a compound one.
|
||
fn cols_disp(cols: &[String]) -> String {
|
||
if cols.len() == 1 {
|
||
cols[0].clone()
|
||
} else {
|
||
format!("({})", cols.join(", "))
|
||
}
|
||
}
|
||
|
||
#[must_use]
|
||
pub fn render_structure(desc: &TableDescription) -> Vec<String> {
|
||
let mut out = structure_box_lines(desc);
|
||
out.extend(relationship_prose_lines(desc));
|
||
out.extend(index_lines(desc));
|
||
out.extend(constraint_lines(desc));
|
||
out
|
||
}
|
||
|
||
/// The table-name header line + the box-drawn column / type /
|
||
/// constraint table. Shared by the prose [`render_structure`] and the
|
||
/// diagram [`render_structure_with_diagrams`] (ADR-0044).
|
||
fn structure_box_lines(desc: &TableDescription) -> Vec<String> {
|
||
let mut out: Vec<String> = vec![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();
|
||
// Every cell is a keyword/text string, so left-align throughout.
|
||
let alignments = vec![Alignment::Left, Alignment::Left, Alignment::Left];
|
||
out.extend(render_table(&header_cells, &body, &alignments));
|
||
out
|
||
}
|
||
|
||
/// The `References:` / `Referenced by:` prose blocks (ADR-0016 §5),
|
||
/// retained for the incidental DDL echoes (ADR-0044 §1).
|
||
fn relationship_prose_lines(desc: &TableDescription) -> Vec<String> {
|
||
let mut out: Vec<String> = Vec::new();
|
||
if !desc.outbound_relationships.is_empty() {
|
||
out.push("References:".to_string());
|
||
for r in &desc.outbound_relationships {
|
||
out.push(format!(
|
||
" {} → {}.{} ({}, on delete {}, on update {})",
|
||
cols_disp(&r.local_columns),
|
||
r.other_table,
|
||
cols_disp(&r.other_columns),
|
||
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,
|
||
cols_disp(&r.other_columns),
|
||
cols_disp(&r.local_columns),
|
||
r.name,
|
||
r.on_delete,
|
||
r.on_update,
|
||
));
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Indexes section (ADR-0025), only when the table carries a
|
||
/// user-created index. A UNIQUE index is marked `[unique]` (ADR-0035
|
||
/// §4d).
|
||
fn index_lines(desc: &TableDescription) -> Vec<String> {
|
||
let mut out: Vec<String> = Vec::new();
|
||
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(", "),
|
||
));
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Table-level constraints (ADR-0035 §4i b): composite `UNIQUE (a, b)`
|
||
/// and table `CHECK (…)`. Column-level constraints already show in the
|
||
/// per-column "Constraints" column; this is the multi-column / named
|
||
/// set, each with its addressable name where it has one.
|
||
fn constraint_lines(desc: &TableDescription) -> Vec<String> {
|
||
let mut out: Vec<String> = Vec::new();
|
||
if !desc.unique_constraints.is_empty() || !desc.check_constraints.is_empty() {
|
||
out.push("Table constraints:".to_string());
|
||
for cols in &desc.unique_constraints {
|
||
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,
|
||
/// The rule **under a full-width title row** that introduces the
|
||
/// body's column split (ADR-0044 §2.1): `├──┬──┤`.
|
||
TitleUnderline,
|
||
}
|
||
|
||
fn border_row(widths: &[usize], kind: BorderRow) -> String {
|
||
let (left, mid, right) = match kind {
|
||
BorderRow::Top => ('┌', '┬', '┐'),
|
||
BorderRow::HeaderUnderline => ('├', '┼', '┤'),
|
||
BorderRow::Bottom => ('└', '┴', '┘'),
|
||
BorderRow::TitleUnderline => ('├', '┬', '┤'),
|
||
};
|
||
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
|
||
}
|
||
|
||
// ── Relationship visualization (ADR-0044) ──────────────────────────
|
||
//
|
||
// A relationship diagram draws two table boxes joined by a connector:
|
||
// child (FK holder) on the left, parent (referenced) on the right, the
|
||
// arrow pointing child → parent, cardinality `n … 1`. The renderer is
|
||
// decoupled from the db structs — `build_diagram_table` adapts a
|
||
// `TableDescription`, and the layout/routing logic is unit-testable on
|
||
// plain `DiagramTable`s. Output is styled `OutputLine`s (ADR-0044 §5)
|
||
// composed from per-box styled segments. Side-by-side when the width
|
||
// allows, vertical-stack fallback otherwise (§3).
|
||
|
||
/// One column as it appears inside a diagram box.
|
||
pub(crate) struct DiagramCol {
|
||
/// Column name.
|
||
pub name: String,
|
||
/// Type keyword; `None` in a compact box (`show table`) where only
|
||
/// the participating column name is shown.
|
||
pub type_text: Option<String>,
|
||
/// Whether the column is part of its table's primary key.
|
||
pub pk: bool,
|
||
/// Whether the column is an endpoint of the relationship drawn.
|
||
pub endpoint: bool,
|
||
}
|
||
|
||
/// A table as drawn in a relationship diagram.
|
||
pub(crate) struct DiagramTable {
|
||
/// Table name (the box's bold title row).
|
||
pub name: String,
|
||
/// Columns shown in the box (all for a full box, only the
|
||
/// participating ones for a compact box).
|
||
pub cols: Vec<DiagramCol>,
|
||
}
|
||
|
||
/// The horizontal gutter between two side-by-side boxes.
|
||
const GUTTER: usize = 18;
|
||
|
||
/// A styled line under construction: text plus its per-span runs
|
||
/// (ADR-0028 §5 / ADR-0044 §5). Segments compose by concatenation
|
||
/// with run offsets shifted, so two boxes + a gutter merge onto one
|
||
/// line without losing styling.
|
||
#[derive(Clone)]
|
||
struct Seg {
|
||
text: String,
|
||
runs: Vec<OutputSpan>,
|
||
}
|
||
|
||
impl Seg {
|
||
const fn new() -> Self {
|
||
Self {
|
||
text: String::new(),
|
||
runs: Vec::new(),
|
||
}
|
||
}
|
||
|
||
/// Append `s` as a run of `class`. Empty strings are ignored.
|
||
fn push(&mut self, s: &str, class: OutputStyleClass) {
|
||
if s.is_empty() {
|
||
return;
|
||
}
|
||
let start = self.text.len();
|
||
self.text.push_str(s);
|
||
self.runs.push(OutputSpan {
|
||
byte_range: (start, self.text.len()),
|
||
class,
|
||
});
|
||
}
|
||
|
||
/// Append `n` spaces of `class` (padding).
|
||
fn pad(&mut self, n: usize, class: OutputStyleClass) {
|
||
if n > 0 {
|
||
self.push(&" ".repeat(n), class);
|
||
}
|
||
}
|
||
|
||
/// Concatenate another segment, shifting its run offsets.
|
||
fn append(&mut self, other: &Self) {
|
||
let base = self.text.len();
|
||
self.text.push_str(&other.text);
|
||
for r in &other.runs {
|
||
self.runs.push(OutputSpan {
|
||
byte_range: (r.byte_range.0 + base, r.byte_range.1 + base),
|
||
class: r.class,
|
||
});
|
||
}
|
||
}
|
||
|
||
fn into_line(self, mode: Mode) -> OutputLine {
|
||
OutputLine::styled(self.text, OutputKind::System, mode, self.runs)
|
||
}
|
||
}
|
||
|
||
/// A laid-out box: equal-width styled lines plus the line index of
|
||
/// each endpoint column (where a connector attaches).
|
||
struct BoxLayout {
|
||
segs: Vec<Seg>,
|
||
width: usize,
|
||
endpoint_rows: Vec<usize>,
|
||
}
|
||
|
||
use crate::app::OutputStyleClass::{
|
||
DiagramCardinality as Card, DiagramConnector as Conn, DiagramKey as Key,
|
||
DiagramTableName as TitleClass, Neutral,
|
||
};
|
||
|
||
/// A whole-line connector segment (borders / rules) of `text`.
|
||
fn conn_line(text: String) -> Seg {
|
||
let mut seg = Seg::new();
|
||
seg.push(&text, Conn);
|
||
seg
|
||
}
|
||
|
||
/// Lay out one table box: a full-width bold title row over a 1- or
|
||
/// 2-column body (label + optional type), styled per ADR-0044 §5.
|
||
fn render_box(t: &DiagramTable) -> BoxLayout {
|
||
let has_types = t.cols.iter().any(|c| c.type_text.is_some());
|
||
|
||
// Per-column label display width = name + ` (PK)` + ` ●` markers.
|
||
let label_w = t
|
||
.cols
|
||
.iter()
|
||
.map(|c| {
|
||
cell_width(&c.name)
|
||
+ usize::from(c.pk) * 5 // " (PK)"
|
||
+ usize::from(c.endpoint) * 2 // " ●"
|
||
})
|
||
.max()
|
||
.unwrap_or(0);
|
||
let mut widths = vec![label_w];
|
||
if has_types {
|
||
let type_w = t
|
||
.cols
|
||
.iter()
|
||
.map(|c| cell_width(c.type_text.as_deref().unwrap_or("")))
|
||
.max()
|
||
.unwrap_or(0);
|
||
widths.push(type_w);
|
||
}
|
||
|
||
// Inner width between the side borders == a body border's width
|
||
// minus the two corners: Σ(w+2) over columns + (ncols−1) dividers.
|
||
let ncols = widths.len();
|
||
let body_inner: usize = widths.iter().map(|w| w + 2).sum::<usize>() + (ncols - 1);
|
||
// The title needs `name` + a space each side; if that exceeds the
|
||
// body width, widen the (first) label column so every row aligns.
|
||
let title_min = cell_width(&t.name) + 2;
|
||
let inner = if title_min > body_inner {
|
||
widths[0] += title_min - body_inner;
|
||
title_min
|
||
} else {
|
||
body_inner
|
||
};
|
||
|
||
let mut segs: Vec<Seg> = Vec::with_capacity(t.cols.len() + 4);
|
||
segs.push(conn_line(h_border(inner, '┌', '┐'))); // top: title spans
|
||
segs.push(title_seg(&t.name, inner));
|
||
segs.push(conn_line(border_row(&widths, BorderRow::TitleUnderline)));
|
||
for c in &t.cols {
|
||
// Use the (possibly title-widened) label column width so the
|
||
// body cells pad to the box width even when the name is wider.
|
||
segs.push(body_seg(c, widths[0], has_types.then(|| widths[1])));
|
||
}
|
||
segs.push(conn_line(border_row(&widths, BorderRow::Bottom)));
|
||
|
||
// Body row j sits at line index 3 (0=top, 1=title, 2=rule, 3+ body).
|
||
let endpoint_rows = t
|
||
.cols
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(_, c)| c.endpoint)
|
||
.map(|(j, _)| 3 + j)
|
||
.collect();
|
||
|
||
BoxLayout {
|
||
segs,
|
||
width: inner + 2,
|
||
endpoint_rows,
|
||
}
|
||
}
|
||
|
||
/// A plain horizontal border of `inner` dashes between two corners.
|
||
fn h_border(inner: usize, left: char, right: char) -> String {
|
||
let mut s = String::new();
|
||
s.push(left);
|
||
for _ in 0..inner {
|
||
s.push('─');
|
||
}
|
||
s.push(right);
|
||
s
|
||
}
|
||
|
||
/// The full-width title row `│ name │` (name in the
|
||
/// stand-out table-name style), padded to `inner`.
|
||
fn title_seg(name: &str, inner: usize) -> Seg {
|
||
let mut seg = Seg::new();
|
||
seg.push("│", Conn);
|
||
seg.push(" ", Conn);
|
||
seg.push(name, TitleClass);
|
||
seg.pad(inner.saturating_sub(1 + cell_width(name)), Conn);
|
||
seg.push("│", Conn);
|
||
seg
|
||
}
|
||
|
||
/// One body row: `│ name (PK) ● │ type │`, markers in the key style,
|
||
/// reproducing the byte layout of [`content_row`] so widths line up.
|
||
fn body_seg(c: &DiagramCol, label_w: usize, type_w: Option<usize>) -> Seg {
|
||
let mut seg = Seg::new();
|
||
seg.push("│", Conn);
|
||
// Label cell.
|
||
seg.push(" ", Conn);
|
||
let mut used = cell_width(&c.name);
|
||
seg.push(&c.name, Neutral);
|
||
if c.pk {
|
||
seg.push(" (PK)", Key);
|
||
used += 5;
|
||
}
|
||
if c.endpoint {
|
||
seg.push(" ●", Key);
|
||
used += 2;
|
||
}
|
||
seg.pad(label_w.saturating_sub(used), Neutral);
|
||
seg.push(" ", Conn);
|
||
seg.push("│", Conn);
|
||
// Type cell (full box only).
|
||
if let Some(tw) = type_w {
|
||
let t = c.type_text.clone().unwrap_or_default();
|
||
seg.push(" ", Conn);
|
||
seg.push(&t, Neutral);
|
||
seg.pad(tw.saturating_sub(cell_width(&t)), Neutral);
|
||
seg.push(" ", Conn);
|
||
seg.push("│", Conn);
|
||
}
|
||
seg
|
||
}
|
||
|
||
/// One row of the gutter as a styled segment: routes the connector
|
||
/// from the child endpoint row (`crow`) to the parent endpoint row
|
||
/// (`prow`), `n` at the child end and `1` at the parent end, a `▶`
|
||
/// arrowhead into the parent. Handles the straight (same height) and
|
||
/// jogged (differing height) cases.
|
||
fn gutter_seg(i: usize, crow: usize, prow: usize, w: usize) -> Seg {
|
||
let mut cells = vec![' '; w];
|
||
let vc = w / 2;
|
||
if crow == prow {
|
||
if i == crow {
|
||
for c in &mut cells[1..w - 1] {
|
||
*c = '─';
|
||
}
|
||
cells[0] = 'n';
|
||
cells[w - 2] = '1';
|
||
cells[w - 1] = '▶';
|
||
}
|
||
} else if i == crow {
|
||
for c in &mut cells[..vc] {
|
||
*c = '─';
|
||
}
|
||
cells[vc] = if crow < prow { '┐' } else { '┘' };
|
||
cells[0] = 'n';
|
||
} else if i == prow {
|
||
cells[vc] = if prow > crow { '└' } else { '┌' };
|
||
for c in &mut cells[vc + 1..w - 1] {
|
||
*c = '─';
|
||
}
|
||
cells[w - 2] = '1';
|
||
cells[w - 1] = '▶';
|
||
} else if i > crow.min(prow) && i < crow.max(prow) {
|
||
cells[vc] = '│';
|
||
}
|
||
|
||
let mut seg = Seg::new();
|
||
for ch in cells {
|
||
let class = if ch == 'n' || ch == '1' { Card } else { Conn };
|
||
seg.push(&ch.to_string(), class);
|
||
}
|
||
seg
|
||
}
|
||
|
||
/// The `on delete … · on update …` label below a diagram (muted).
|
||
fn action_seg(on_delete: &str, on_update: &str) -> Seg {
|
||
let mut seg = Seg::new();
|
||
seg.push(
|
||
&format!(" on delete {on_delete} · on update {on_update}"),
|
||
crate::app::OutputStyleClass::Hint,
|
||
);
|
||
seg
|
||
}
|
||
|
||
/// A blank styled line of `w` spaces (fills a shorter box's side).
|
||
fn blank_seg(w: usize) -> Seg {
|
||
let mut seg = Seg::new();
|
||
seg.pad(w, Neutral);
|
||
seg
|
||
}
|
||
|
||
/// Two boxes side by side, joined by the gutter connector (ADR-0044
|
||
/// §2.3), with the actions line beneath.
|
||
fn compose_side_by_side(
|
||
cb: &BoxLayout,
|
||
pb: &BoxLayout,
|
||
crow: usize,
|
||
prow: usize,
|
||
on_delete: &str,
|
||
on_update: &str,
|
||
) -> Vec<Seg> {
|
||
let height = cb.segs.len().max(pb.segs.len());
|
||
let blank_l = blank_seg(cb.width);
|
||
let blank_r = blank_seg(pb.width);
|
||
let mut out: Vec<Seg> = Vec::with_capacity(height + 1);
|
||
for i in 0..height {
|
||
let mut seg = Seg::new();
|
||
seg.append(cb.segs.get(i).unwrap_or(&blank_l));
|
||
seg.append(&gutter_seg(i, crow, prow, GUTTER));
|
||
seg.append(pb.segs.get(i).unwrap_or(&blank_r));
|
||
out.push(seg);
|
||
}
|
||
out.push(action_seg(on_delete, on_update));
|
||
out
|
||
}
|
||
|
||
/// Vertical-stack fallback for narrow terminals (ADR-0044 §3): child
|
||
/// box, a downward connector carrying the actions, then the parent box.
|
||
fn compose_vertical(
|
||
cb: &BoxLayout,
|
||
pb: &BoxLayout,
|
||
on_delete: &str,
|
||
on_update: &str,
|
||
) -> Vec<Seg> {
|
||
let indent = " ";
|
||
let mut out: Vec<Seg> = cb.segs.clone();
|
||
let mut a = Seg::new();
|
||
a.push(indent, Conn);
|
||
a.push("│ n", Card);
|
||
a.push(&format!(" on delete {on_delete}"), crate::app::OutputStyleClass::Hint);
|
||
out.push(a);
|
||
let mut b = Seg::new();
|
||
b.push(indent, Conn);
|
||
b.push("▼ 1", Card);
|
||
b.push(&format!(" on update {on_update}"), crate::app::OutputStyleClass::Hint);
|
||
out.push(b);
|
||
out.extend(pb.segs.clone());
|
||
out
|
||
}
|
||
|
||
/// Lay out a relationship between two `DiagramTable`s at `width`,
|
||
/// choosing side-by-side or the vertical fallback (ADR-0044 §3).
|
||
fn render_relationship_layout(
|
||
child: &DiagramTable,
|
||
parent: &DiagramTable,
|
||
on_delete: &str,
|
||
on_update: &str,
|
||
width: usize,
|
||
) -> Vec<Seg> {
|
||
let cb = render_box(child);
|
||
let pb = render_box(parent);
|
||
let crow = cb.endpoint_rows.first().copied().unwrap_or(0);
|
||
let prow = pb.endpoint_rows.first().copied().unwrap_or(0);
|
||
if cb.width + GUTTER + pb.width <= width.max(1) {
|
||
compose_side_by_side(&cb, &pb, crow, prow, on_delete, on_update)
|
||
} else {
|
||
compose_vertical(&cb, &pb, on_delete, on_update)
|
||
}
|
||
}
|
||
|
||
/// Build a full-box `DiagramTable` from a table description, marking
|
||
/// the columns that are this relationship's endpoints.
|
||
fn build_diagram_table(desc: &TableDescription, endpoint_cols: &[String]) -> DiagramTable {
|
||
DiagramTable {
|
||
name: desc.name.clone(),
|
||
cols: desc
|
||
.columns
|
||
.iter()
|
||
.map(|c| DiagramCol {
|
||
name: c.name.clone(),
|
||
type_text: Some(type_display(c)),
|
||
pk: c.primary_key,
|
||
endpoint: endpoint_cols.iter().any(|e| e == &c.name),
|
||
})
|
||
.collect(),
|
||
}
|
||
}
|
||
|
||
/// Render one relationship as a styled diagram (ADR-0044): the full
|
||
/// `show relationship <name>` view, both tables as full structure
|
||
/// boxes joined by a connector, laid out for `width`.
|
||
pub(crate) fn render_relationship_diagram(
|
||
data: &crate::db::RelationshipDiagramData,
|
||
width: u16,
|
||
mode: Mode,
|
||
) -> Vec<OutputLine> {
|
||
let child = build_diagram_table(&data.child, &data.rel.child_columns);
|
||
let parent = build_diagram_table(&data.parent, &data.rel.parent_columns);
|
||
let on_delete = data.rel.on_delete.to_string();
|
||
let on_update = data.rel.on_update.to_string();
|
||
render_relationship_layout(&child, &parent, &on_delete, &on_update, width as usize)
|
||
.into_iter()
|
||
.map(|s| s.into_line(mode))
|
||
.collect()
|
||
}
|
||
|
||
/// A plain (unstyled) system output line — falls back to whole-line
|
||
/// `System` styling, exactly like `note_system`.
|
||
const fn plain_system(text: String, mode: Mode) -> OutputLine {
|
||
OutputLine {
|
||
text,
|
||
kind: OutputKind::System,
|
||
mode_at_submission: mode,
|
||
styled_runs: None,
|
||
status: None,
|
||
}
|
||
}
|
||
|
||
/// A compact (name-only) box for one endpoint of a `show table`
|
||
/// relationship diagram (ADR-0044 §4): the table name + just the
|
||
/// participating column(s), all marked as endpoints.
|
||
fn compact_table(name: &str, cols: &[String]) -> DiagramTable {
|
||
DiagramTable {
|
||
name: name.to_string(),
|
||
cols: cols
|
||
.iter()
|
||
.map(|c| DiagramCol {
|
||
name: c.clone(),
|
||
type_text: None,
|
||
pk: false,
|
||
endpoint: true,
|
||
})
|
||
.collect(),
|
||
}
|
||
}
|
||
|
||
/// One relationship of the focal table as a compact connector diagram
|
||
/// (ADR-0044 §4). `outbound` = the focal table is the child (FK
|
||
/// holder, drawn left); otherwise it is the parent (drawn right).
|
||
fn render_compact_relationship(
|
||
focal: &str,
|
||
rel: &crate::db::RelationshipEnd,
|
||
outbound: bool,
|
||
width: usize,
|
||
) -> Vec<Seg> {
|
||
let focal_box = compact_table(focal, &rel.local_columns);
|
||
let other_box = compact_table(&rel.other_table, &rel.other_columns);
|
||
let (child, parent) = if outbound {
|
||
(focal_box, other_box)
|
||
} else {
|
||
(other_box, focal_box)
|
||
};
|
||
render_relationship_layout(
|
||
&child,
|
||
&parent,
|
||
&rel.on_delete.to_string(),
|
||
&rel.on_update.to_string(),
|
||
width,
|
||
)
|
||
}
|
||
|
||
/// `show table <T>` and relationship-DDL echoes (ADR-0044 §1, Diagram
|
||
/// mode): the focal structure box, then a **Relationships** section of
|
||
/// compact stacked diagrams, then indexes / table constraints. Box,
|
||
/// index and constraint sections are plain system lines; the diagrams
|
||
/// are styled.
|
||
pub(crate) fn render_structure_with_diagrams(
|
||
desc: &TableDescription,
|
||
width: u16,
|
||
mode: Mode,
|
||
) -> Vec<OutputLine> {
|
||
let mut out: Vec<OutputLine> = structure_box_lines(desc)
|
||
.into_iter()
|
||
.map(|s| plain_system(s, mode))
|
||
.collect();
|
||
|
||
if !desc.outbound_relationships.is_empty() || !desc.inbound_relationships.is_empty() {
|
||
out.push(plain_system("Relationships".to_string(), mode));
|
||
// Outbound (this table is the child) first, then inbound, each
|
||
// a compact connector diagram stacked vertically (ADR-0044 §4).
|
||
for rel in &desc.outbound_relationships {
|
||
for seg in render_compact_relationship(&desc.name, rel, true, width as usize) {
|
||
out.push(seg.into_line(mode));
|
||
}
|
||
}
|
||
for rel in &desc.inbound_relationships {
|
||
for seg in render_compact_relationship(&desc.name, rel, false, width as usize) {
|
||
out.push(seg.into_line(mode));
|
||
}
|
||
}
|
||
}
|
||
|
||
for s in index_lines(desc) {
|
||
out.push(plain_system(s, mode));
|
||
}
|
||
for s in constraint_lines(desc) {
|
||
out.push(plain_system(s, mode));
|
||
}
|
||
out
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::db::{ColumnDescription, IndexInfo, RelationshipEnd};
|
||
use crate::dsl::ReferentialAction;
|
||
use insta::assert_snapshot;
|
||
|
||
// ── Relationship visualization (ADR-0044) ──────────────────────
|
||
|
||
fn dcol(name: &str, ty: &str, pk: bool, endpoint: bool) -> DiagramCol {
|
||
DiagramCol {
|
||
name: name.to_string(),
|
||
type_text: Some(ty.to_string()),
|
||
pk,
|
||
endpoint,
|
||
}
|
||
}
|
||
|
||
/// `orders.customer_id → customers.id`, the canonical 1:n example.
|
||
fn orders_to_customers() -> (DiagramTable, DiagramTable) {
|
||
let child = DiagramTable {
|
||
name: "orders".to_string(),
|
||
cols: vec![
|
||
dcol("id", "int", true, false),
|
||
dcol("customer_id", "int", false, true),
|
||
dcol("total", "real", false, false),
|
||
],
|
||
};
|
||
let parent = DiagramTable {
|
||
name: "customers".to_string(),
|
||
cols: vec![
|
||
dcol("id", "int", true, true),
|
||
dcol("name", "text", false, false),
|
||
dcol("email", "text", false, false),
|
||
],
|
||
};
|
||
(child, parent)
|
||
}
|
||
|
||
/// Join a laid-out diagram's segment text for assertions/snapshots.
|
||
fn layout_text(child: &DiagramTable, parent: &DiagramTable, width: usize) -> String {
|
||
render_relationship_layout(child, parent, "cascade", "no action", width)
|
||
.iter()
|
||
.map(|s| s.text.clone())
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
}
|
||
|
||
#[test]
|
||
fn relationship_diagram_single_column_side_by_side_snapshot() {
|
||
let (child, parent) = orders_to_customers();
|
||
// Wide width forces the side-by-side layout.
|
||
let out = layout_text(&child, &parent, 200);
|
||
assert_snapshot!(out);
|
||
}
|
||
|
||
#[test]
|
||
fn relationship_diagram_carries_names_cardinality_arrow_and_actions() {
|
||
let (child, parent) = orders_to_customers();
|
||
let out = layout_text(&child, &parent, 200);
|
||
// Both tables named, FK marker present, connector + cardinality,
|
||
// child→parent arrow, and the referential actions line.
|
||
assert!(out.contains("orders"), "child name:\n{out}");
|
||
assert!(out.contains("customers"), "parent name:\n{out}");
|
||
assert!(out.contains("customer_id ●"), "FK marker:\n{out}");
|
||
assert!(out.contains("id (PK) ●"), "parent endpoint marker:\n{out}");
|
||
assert!(out.contains('▶'), "arrowhead:\n{out}");
|
||
assert!(out.contains('n') && out.contains('1'), "cardinality:\n{out}");
|
||
assert!(
|
||
out.contains("on delete cascade · on update no action"),
|
||
"actions:\n{out}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn relationship_diagram_title_uses_table_name_style() {
|
||
use crate::app::OutputStyleClass;
|
||
let (child, parent) = orders_to_customers();
|
||
let segs = render_relationship_layout(&child, &parent, "cascade", "no action", 200);
|
||
// A span styles the literal table name in the stand-out class
|
||
// (ADR-0044 §2.1 — the name must not read as a column).
|
||
let styled = segs.iter().any(|s| {
|
||
s.runs.iter().any(|r| {
|
||
r.class == OutputStyleClass::DiagramTableName
|
||
&& &s.text[r.byte_range.0..r.byte_range.1] == "orders"
|
||
})
|
||
});
|
||
assert!(styled, "table name should carry DiagramTableName style");
|
||
}
|
||
|
||
#[test]
|
||
fn relationship_diagram_vertical_fallback_when_narrow() {
|
||
let (child, parent) = orders_to_customers();
|
||
// A width too small for two boxes side by side stacks them.
|
||
let out = layout_text(&child, &parent, 20);
|
||
assert!(out.contains('▼'), "vertical connector:\n{out}");
|
||
assert!(!out.contains('▶'), "no side-by-side arrow:\n{out}");
|
||
let ci = out.find("orders").expect("child");
|
||
let pi = out.find("customers").expect("parent");
|
||
assert!(pi > ci, "parent stacked below child:\n{out}");
|
||
}
|
||
|
||
#[test]
|
||
fn render_structure_with_diagrams_replaces_prose_with_compact_diagrams() {
|
||
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_columns: vec!["cust_id".to_string()],
|
||
local_columns: vec!["id".to_string()],
|
||
on_delete: ReferentialAction::Cascade,
|
||
on_update: ReferentialAction::NoAction,
|
||
}],
|
||
indexes: Vec::new(),
|
||
unique_constraints: Vec::new(),
|
||
check_constraints: Vec::new(),
|
||
};
|
||
let lines = render_structure_with_diagrams(&desc, 200, Mode::Simple);
|
||
let text = lines
|
||
.iter()
|
||
.map(|l| l.text.as_str())
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
// Diagram form: a Relationships heading + a connector, NOT the
|
||
// prose `Referenced by:` block.
|
||
assert!(text.contains("Relationships"), "heading:\n{text}");
|
||
assert!(!text.contains("Referenced by:"), "no prose block:\n{text}");
|
||
assert!(text.contains("Customers"), "focal box:\n{text}");
|
||
assert!(text.contains("Orders"), "neighbour box:\n{text}");
|
||
assert!(text.contains('▶'), "connector arrow:\n{text}");
|
||
// Box lines plain; diagram lines styled.
|
||
assert!(
|
||
lines.iter().any(|l| l.styled_runs.is_some()),
|
||
"styled diagram lines",
|
||
);
|
||
assert!(
|
||
lines.iter().any(|l| l.styled_runs.is_none()),
|
||
"plain box lines",
|
||
);
|
||
assert_snapshot!(text);
|
||
}
|
||
|
||
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_columns: vec!["cust_id".to_string()],
|
||
local_columns: vec!["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}");
|
||
}
|
||
}
|