From a0ee32393f8b167c68518730e8d8cac2c8ab46b9 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 06:56:35 +0000 Subject: [PATCH] feat: show table renders relationships as compact diagrams (ADR-0044) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit show table 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). --- src/app.rs | 24 ++- src/output_render.rs | 202 +++++++++++++++--- ..._replaces_prose_with_compact_diagrams.snap | 17 ++ tests/it/show_list.rs | 35 +++ tests/it/walking_skeleton.rs | 12 +- 5 files changed, 258 insertions(+), 32 deletions(-) create mode 100644 src/snapshots/rdbms_playground__output_render__tests__render_structure_with_diagrams_replaces_prose_with_compact_diagrams.snap diff --git a/src/app.rs b/src/app.rs index 8f0dc56..75ec9d9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1689,8 +1689,28 @@ impl App { fn handle_dsl_success(&mut self, command: &Command, description: Option) { self.note_ok_summary(command); if let Some(desc) = description.as_ref() { - for line in crate::output_render::render_structure(desc) { - self.note_system(line); + // 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; diff --git a/src/output_render.rs b/src/output_render.rs index ff35cb8..01f1efb 100644 --- a/src/output_render.rs +++ b/src/output_render.rs @@ -90,9 +90,18 @@ fn cols_disp(cols: &[String]) -> String { #[must_use] pub fn render_structure(desc: &TableDescription) -> Vec { - let mut out: Vec = 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 { + let mut out: Vec = 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 { let body: Vec> = 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 { + let mut out: Vec = 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 { )); } } + 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 { + let mut out: Vec = 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 { )); } } + 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 { + let mut out: Vec = 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 `. out.push(format!( " {}: unique ({})", crate::db::unique_constraint_name(cols), @@ -185,7 +194,6 @@ pub fn render_structure(desc: &TableDescription) -> Vec { } } } - 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 { + 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 ` 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 { + let mut out: Vec = 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::>() + .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(), diff --git a/src/snapshots/rdbms_playground__output_render__tests__render_structure_with_diagrams_replaces_prose_with_compact_diagrams.snap b/src/snapshots/rdbms_playground__output_render__tests__render_structure_with_diagrams_replaces_prose_with_compact_diagrams.snap new file mode 100644 index 0000000..2c8cf8f --- /dev/null +++ b/src/snapshots/rdbms_playground__output_render__tests__render_structure_with_diagrams_replaces_prose_with_compact_diagrams.snap @@ -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 diff --git a/tests/it/show_list.rs b/tests/it/show_list.rs index 12e9e24..f3750e7 100644 --- a/tests/it/show_list.rs +++ b/tests/it/show_list.rs @@ -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::>() + .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}"); +} diff --git a/tests/it/walking_skeleton.rs b/tests/it/walking_skeleton.rs index 2be6ea4..24646e7 100644 --- a/tests/it/walking_skeleton.rs +++ b/tests/it/walking_skeleton.rs @@ -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.