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:
+22
-2
@@ -1689,8 +1689,28 @@ 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() {
|
||||
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;
|
||||
|
||||
+175
-27
@@ -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(),
|
||||
|
||||
+17
@@ -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
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user