feat: show table renders relationships as compact diagrams (ADR-0044)

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).
This commit is contained in:
claude@clouddev1
2026-06-10 06:56:35 +00:00
parent cad90ec4a5
commit a0ee32393f
5 changed files with 258 additions and 32 deletions
+20
View File
@@ -1689,10 +1689,30 @@ impl App {
fn handle_dsl_success(&mut self, command: &Command, description: Option<TableDescription>) {
self.note_ok_summary(command);
if let Some(desc) = description.as_ref() {
// ADR-0044 §1 "relationship-relevant" reach: when a
// relationship is the subject of the command (`show table`,
// `add`/`drop relationship`), render the table's
// relationships as compact diagrams; every other DDL echo
// keeps the prose `References:` / `Referenced by:` form.
if matches!(
command,
Command::ShowTable { .. }
| Command::AddRelationship { .. }
| Command::DropRelationship { .. }
) {
for line in crate::output_render::render_structure_with_diagrams(
desc,
self.last_output_width,
self.mode,
) {
self.push_output(line);
}
} else {
for line in crate::output_render::render_structure(desc) {
self.note_system(line);
}
}
}
self.current_table = description;
}
+175 -27
View File
@@ -90,9 +90,18 @@ fn cols_disp(cols: &[String]) -> String {
#[must_use]
pub fn render_structure(desc: &TableDescription) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
out.push(desc.name.clone());
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(),
@@ -101,22 +110,18 @@ pub fn render_structure(desc: &TableDescription) -> Vec<String> {
let body: Vec<Vec<String>> = desc
.columns
.iter()
.map(|c| {
vec![
c.name.clone(),
type_display(c),
constraints_display(c),
]
})
.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.
// 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 {
@@ -145,11 +150,14 @@ pub fn render_structure(desc: &TableDescription) -> Vec<String> {
));
}
}
out
}
// 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).
/// 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 {
@@ -161,17 +169,18 @@ pub fn render_structure(desc: &TableDescription) -> Vec<String> {
));
}
}
out
}
// 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.
/// 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 {
// 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),
@@ -185,7 +194,6 @@ pub fn render_structure(desc: &TableDescription) -> Vec<String> {
}
}
}
out
}
@@ -701,7 +709,9 @@ fn render_box(t: &DiagramTable) -> BoxLayout {
segs.push(title_seg(&t.name, inner));
segs.push(conn_line(border_row(&widths, BorderRow::TitleUnderline)));
for c in &t.cols {
segs.push(body_seg(c, label_w, has_types.then_some(widths[1])));
// 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)));
@@ -940,6 +950,101 @@ pub(crate) fn render_relationship_diagram(
.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::*;
@@ -1042,6 +1147,49 @@ mod tests {
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(),
@@ -0,0 +1,17 @@
---
source: src/output_render.rs
expression: text
---
Customers
┌──────┬────────┬─────────────┐
│ Name │ Type │ Constraints │
├──────┼────────┼─────────────┤
│ id │ serial │ PK │
└──────┴────────┴─────────────┘
Relationships
┌───────────┐ ┌───────────┐
│ Orders │ │ Customers │
├───────────┤ ├───────────┤
│ cust_id ● │n───────────────1▶│ id ● │
└───────────┘ └───────────┘
on delete cascade · on update no action
+35
View File
@@ -454,3 +454,38 @@ fn app_show_relationship_not_found_shows_friendly_line() {
"friendly not-found line",
);
}
#[test]
fn app_show_table_renders_relationships_as_compact_diagrams() {
let (_p, db, _dir) = open_project_db();
let rt = rt();
rt.block_on(seed_schema(&db));
// Orders holds the FK to Customers — an outbound relationship.
let desc = rt
.block_on(db.describe_table("Orders".to_string(), None))
.expect("describe Orders");
let mut app = App::new();
app.output.push_back(rdbms_playground::app::OutputLine::echo(
"show table Orders",
Mode::Simple,
));
app.update(AppEvent::DslSucceeded {
command: Command::ShowTable {
name: "Orders".to_string(),
},
description: Some(desc),
echo: None,
});
let text: String = app
.output
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
// The focal structure box, then a diagram (not the prose block).
assert!(text.contains("Relationships"), "diagram heading: {text}");
assert!(!text.contains("References:"), "prose suppressed: {text}");
assert!(text.contains("Customers"), "neighbour box: {text}");
assert!(text.contains('▶'), "connector arrow: {text}");
}
+9 -3
View File
@@ -473,9 +473,15 @@ fn add_relationship_flow_shows_parent_side_with_inbound_section() {
echo: None,
});
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
assert!(rendered.contains("Referenced by:"), "{rendered}");
assert!(rendered.contains("Orders.CustId"), "{rendered}");
// Tall viewport so the [ok] echo line stays visible above the
// (taller-than-prose) diagram for the endpoint-subject assertion.
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 40);
// ADR-0044: `add relationship` is relationship-relevant, so its echo
// renders the relationship as a compact diagram, not the prose block.
assert!(rendered.contains("Relationships"), "heading: {rendered}");
assert!(rendered.contains("Orders"), "neighbour box: {rendered}");
assert!(rendered.contains("CustId"), "FK column: {rendered}");
assert!(rendered.contains('▶'), "connector: {rendered}");
assert!(rendered.contains("on delete cascade"), "{rendered}");
// The [ok] subject lists the endpoints. Long lines wrap in
// the panel, so we check the first half of the phrase only.