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:
+20
@@ -1689,10 +1689,30 @@ impl App {
|
|||||||
fn handle_dsl_success(&mut self, command: &Command, description: Option<TableDescription>) {
|
fn handle_dsl_success(&mut self, command: &Command, description: Option<TableDescription>) {
|
||||||
self.note_ok_summary(command);
|
self.note_ok_summary(command);
|
||||||
if let Some(desc) = description.as_ref() {
|
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) {
|
for line in crate::output_render::render_structure(desc) {
|
||||||
self.note_system(line);
|
self.note_system(line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
self.current_table = description;
|
self.current_table = description;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+175
-27
@@ -90,9 +90,18 @@ fn cols_disp(cols: &[String]) -> String {
|
|||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn render_structure(desc: &TableDescription) -> Vec<String> {
|
pub fn render_structure(desc: &TableDescription) -> Vec<String> {
|
||||||
let mut out: Vec<String> = Vec::new();
|
let mut out = structure_box_lines(desc);
|
||||||
out.push(desc.name.clone());
|
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![
|
let header_cells = vec![
|
||||||
"Name".to_string(),
|
"Name".to_string(),
|
||||||
"Type".to_string(),
|
"Type".to_string(),
|
||||||
@@ -101,22 +110,18 @@ pub fn render_structure(desc: &TableDescription) -> Vec<String> {
|
|||||||
let body: Vec<Vec<String>> = desc
|
let body: Vec<Vec<String>> = desc
|
||||||
.columns
|
.columns
|
||||||
.iter()
|
.iter()
|
||||||
.map(|c| {
|
.map(|c| vec![c.name.clone(), type_display(c), constraints_display(c)])
|
||||||
vec![
|
|
||||||
c.name.clone(),
|
|
||||||
type_display(c),
|
|
||||||
constraints_display(c),
|
|
||||||
]
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
// Type column gets the same numeric/text rule as data
|
// Every cell is a keyword/text string, so left-align throughout.
|
||||||
// columns by virtue of consistency, but every entry is
|
|
||||||
// a keyword string ("text", "serial", …) so left-align
|
|
||||||
// is correct in every case. Constraints are similarly
|
|
||||||
// textual.
|
|
||||||
let alignments = vec![Alignment::Left, Alignment::Left, Alignment::Left];
|
let alignments = vec![Alignment::Left, Alignment::Left, Alignment::Left];
|
||||||
out.extend(render_table(&header_cells, &body, &alignments));
|
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() {
|
if !desc.outbound_relationships.is_empty() {
|
||||||
out.push("References:".to_string());
|
out.push("References:".to_string());
|
||||||
for r in &desc.outbound_relationships {
|
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
|
/// Indexes section (ADR-0025), only when the table carries a
|
||||||
// carries at least one user-created index. A UNIQUE index is
|
/// user-created index. A UNIQUE index is marked `[unique]` (ADR-0035
|
||||||
// marked `[unique]` so a learner can tell a uniqueness-enforcing
|
/// §4d).
|
||||||
// index from a performance-only one (ADR-0035 §4d).
|
fn index_lines(desc: &TableDescription) -> Vec<String> {
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
if !desc.indexes.is_empty() {
|
if !desc.indexes.is_empty() {
|
||||||
out.push("Indexes:".to_string());
|
out.push("Indexes:".to_string());
|
||||||
for index in &desc.indexes {
|
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)`
|
/// Table-level constraints (ADR-0035 §4i b): composite `UNIQUE (a, b)`
|
||||||
// and table `CHECK (…)` constraints. Single-column UNIQUE / NOT NULL /
|
/// and table `CHECK (…)`. Column-level constraints already show in the
|
||||||
// PK / column-level CHECK already show in the per-column "Constraints"
|
/// per-column "Constraints" column; this is the multi-column / named
|
||||||
// column above; this section is the table-level constraints that span
|
/// set, each with its addressable name where it has one.
|
||||||
// columns or stand alone. A named CHECK shows its name.
|
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() {
|
if !desc.unique_constraints.is_empty() || !desc.check_constraints.is_empty() {
|
||||||
out.push("Table constraints:".to_string());
|
out.push("Table constraints:".to_string());
|
||||||
for cols in &desc.unique_constraints {
|
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!(
|
out.push(format!(
|
||||||
" {}: unique ({})",
|
" {}: unique ({})",
|
||||||
crate::db::unique_constraint_name(cols),
|
crate::db::unique_constraint_name(cols),
|
||||||
@@ -185,7 +194,6 @@ pub fn render_structure(desc: &TableDescription) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,7 +709,9 @@ fn render_box(t: &DiagramTable) -> BoxLayout {
|
|||||||
segs.push(title_seg(&t.name, inner));
|
segs.push(title_seg(&t.name, inner));
|
||||||
segs.push(conn_line(border_row(&widths, BorderRow::TitleUnderline)));
|
segs.push(conn_line(border_row(&widths, BorderRow::TitleUnderline)));
|
||||||
for c in &t.cols {
|
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)));
|
segs.push(conn_line(border_row(&widths, BorderRow::Bottom)));
|
||||||
|
|
||||||
@@ -940,6 +950,101 @@ pub(crate) fn render_relationship_diagram(
|
|||||||
.collect()
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1042,6 +1147,49 @@ mod tests {
|
|||||||
assert!(pi > ci, "parent stacked below child:\n{out}");
|
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 {
|
fn col(name: &str, ty: Type, pk: bool, notnull: bool) -> ColumnDescription {
|
||||||
ColumnDescription {
|
ColumnDescription {
|
||||||
name: name.to_string(),
|
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",
|
"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,
|
echo: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
// Tall viewport so the [ok] echo line stays visible above the
|
||||||
assert!(rendered.contains("Referenced by:"), "{rendered}");
|
// (taller-than-prose) diagram for the endpoint-subject assertion.
|
||||||
assert!(rendered.contains("Orders.CustId"), "{rendered}");
|
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}");
|
assert!(rendered.contains("on delete cascade"), "{rendered}");
|
||||||
// The [ok] subject lists the endpoints. Long lines wrap in
|
// The [ok] subject lists the endpoints. Long lines wrap in
|
||||||
// the panel, so we check the first half of the phrase only.
|
// the panel, so we check the first half of the phrase only.
|
||||||
|
|||||||
Reference in New Issue
Block a user