feat: compound-FK bus routing + complete V1 relationship visualization (ADR-0044)

Completes requirement V1. A compound (multi-column) FK now routes a
bus connector — each paired endpoint's stub merges into a shared
vertical channel that splits to the other side — plus an explicit
"(a, b) ▶ P.(x, y)" pairing line; the bus generalises the single-column
jog (reproducing it exactly, so prior snapshots are unchanged).
Self-referential FKs render as two same-named boxes.

- output_render.rs: gutter_seg routes all endpoint pairs via a
  junction() bus; pairing line for compound FKs; compound, self-ref,
  and compound-from-data (build_diagram_table glue) tests + snapshots
- compound_fk.rs: worker test that show_relationship carries both
  paired column lists into the diagram payload
- db.rs: document do_show_one's now-app-superseded relationship prose
  branch (retained as a worker-API/text fallback; could back a future
  non-visual display option, cf. ADR-0044 OOS-7)

Second /runda pass over the implementation: confirmed ADR-compliance,
UTF-8/byte-range safety, and edge-case routing. The ADR §3 last-resort
helper line was considered and rejected (vertical fallback + ratatui
truncation cover all realistic cases). ADR-0044 marked implemented;
requirements.md V1 -> [x].

Full suite 2207 pass / 0 fail / 1 ignored; clippy nursery clean.
This commit is contained in:
claude@clouddev1
2026-06-10 10:17:09 +00:00
parent a0ee32393f
commit 0a343036d8
8 changed files with 325 additions and 57 deletions
+9
View File
@@ -6010,6 +6010,15 @@ fn do_show_list(
/// labelled block, or a friendly "no such item" line. `Tables` is
/// never routed here (the table singular is `ShowTable`); the
/// defensive arm keeps the match total without a panic.
///
/// **The `Relationships` arm is superseded for the app by
/// `do_show_relationship` (ADR-0044): the runtime reroutes a named
/// `show relationship` to the structured diagram path, so this prose
/// form is no longer shown to users.** It is retained — reachable via
/// the `Database::show_list` worker API and covered by a worker test —
/// as a text fallback that could back a future non-visual display
/// option (cf. ADR-0044 OOS-7's relationship-display setting). The
/// `Indexes` arm remains live (`show index <name>` still routes here).
fn do_show_one(
conn: &Connection,
kind: crate::dsl::command::ShowListKind,
+213 -36
View File
@@ -786,38 +786,64 @@ fn body_seg(c: &DiagramCol, label_w: usize, type_w: Option<usize>) -> Seg {
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 {
/// The box-drawing glyph for a bus junction given which directions it
/// connects (up / down the bus, a child stub from the left, a parent
/// stub to the right).
const fn junction(up: bool, down: bool, left: bool, right: bool) -> char {
match (up, down, left, right) {
(true, true, true, true) => '┼',
(true, true, true, false) => '┤',
(true, true, false, true) => '├',
(true, true, false, false) => '│',
(true, false, true, true) => '┴',
(true, false, true, false) => '┘',
(true, false, false, true) => '└',
(false, true, true, true) => '┬',
(false, true, true, false) => '┐',
(false, true, false, true) => '┌',
(false, false, true, _) | (false, false, false, true) => '─',
_ => '│',
}
}
/// One row of the gutter as a styled segment, routing **all** endpoint
/// pairs (ADR-0044 §2.3 / §2.4): each child endpoint row gets an `n`
/// stub from the left, each parent endpoint row a `1` stub + `▶` to the
/// right, both merging into a shared vertical bus at the centre. For a
/// single-column FK this reduces to the simple jogged connector.
fn gutter_seg(i: usize, child_rows: &[usize], parent_rows: &[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] {
let on_child = child_rows.contains(&i);
let on_parent = parent_rows.contains(&i);
if on_child {
for c in &mut cells[1..vc] {
*c = '─';
}
cells[vc] = if crow < prow { '┐' } else { '┘' };
cells[0] = 'n';
} else if i == prow {
cells[vc] = if prow > crow { '└' } else { '┌' };
}
if on_parent {
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] = '│';
}
// The vertical bus spans the full range of endpoint rows.
let bounds = child_rows
.iter()
.chain(parent_rows)
.copied()
.fold(None, |acc: Option<(usize, usize)>, r| {
Some(acc.map_or((r, r), |(lo, hi)| (lo.min(r), hi.max(r))))
});
if let Some((top, bot)) = bounds
&& i >= top
&& i <= bot
{
cells[vc] = junction(i > top, i < bot, on_child, on_parent);
}
let mut seg = Seg::new();
@@ -845,36 +871,48 @@ fn blank_seg(w: usize) -> Seg {
seg
}
/// Two boxes side by side, joined by the gutter connector (ADR-0044
/// §2.3), with the actions line beneath.
/// The explicit pairing line for a compound FK (ADR-0044 §2.4).
fn pairing_seg(text: &str) -> Seg {
let mut seg = Seg::new();
seg.push(&format!(" {text}"), Neutral);
seg
}
/// Two boxes side by side, joined by the bus connector (ADR-0044
/// §2.3/§2.4), with an optional compound-FK pairing line and the
/// actions line beneath.
fn compose_side_by_side(
cb: &BoxLayout,
pb: &BoxLayout,
crow: usize,
prow: usize,
pairing: Option<&str>,
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);
let mut out: Vec<Seg> = Vec::with_capacity(height + 2);
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(&gutter_seg(i, &cb.endpoint_rows, &pb.endpoint_rows, GUTTER));
seg.append(pb.segs.get(i).unwrap_or(&blank_r));
out.push(seg);
}
if let Some(p) = pairing {
out.push(pairing_seg(p));
}
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.
/// box, a downward connector carrying the actions, then the parent box,
/// and the optional pairing line.
fn compose_vertical(
cb: &BoxLayout,
pb: &BoxLayout,
pairing: Option<&str>,
on_delete: &str,
on_update: &str,
) -> Vec<Seg> {
@@ -883,19 +921,29 @@ fn compose_vertical(
let mut a = Seg::new();
a.push(indent, Conn);
a.push("│ n", Card);
a.push(&format!(" on delete {on_delete}"), crate::app::OutputStyleClass::Hint);
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);
b.push(
&format!(" on update {on_update}"),
crate::app::OutputStyleClass::Hint,
);
out.push(b);
out.extend(pb.segs.clone());
if let Some(p) = pairing {
out.push(pairing_seg(p));
}
out
}
/// Lay out a relationship between two `DiagramTable`s at `width`,
/// choosing side-by-side or the vertical fallback (ADR-0044 §3).
/// choosing side-by-side or the vertical fallback (ADR-0044 §3). A
/// compound FK (>1 paired column) also gets an explicit pairing line.
fn render_relationship_layout(
child: &DiagramTable,
parent: &DiagramTable,
@@ -905,12 +953,30 @@ fn render_relationship_layout(
) -> 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);
let child_cols: Vec<&str> = child
.cols
.iter()
.filter(|c| c.endpoint)
.map(|c| c.name.as_str())
.collect();
let parent_cols: Vec<&str> = parent
.cols
.iter()
.filter(|c| c.endpoint)
.map(|c| c.name.as_str())
.collect();
let pairing = (child_cols.len() > 1).then(|| {
format!(
"({}) ▶ {}.({})",
child_cols.join(", "),
parent.name,
parent_cols.join(", "),
)
});
if cb.width + GUTTER + pb.width <= width.max(1) {
compose_side_by_side(&cb, &pb, crow, prow, on_delete, on_update)
compose_side_by_side(&cb, &pb, pairing.as_deref(), on_delete, on_update)
} else {
compose_vertical(&cb, &pb, on_delete, on_update)
compose_vertical(&cb, &pb, pairing.as_deref(), on_delete, on_update)
}
}
@@ -1147,6 +1213,117 @@ mod tests {
assert!(pi > ci, "parent stacked below child:\n{out}");
}
#[test]
fn relationship_diagram_compound_fk_routes_a_bus_and_pairing_line() {
// A 2-column FK: (cust_region, cust_id) → customers.(region, id).
let child = DiagramTable {
name: "orders".to_string(),
cols: vec![
dcol("cust_region", "text", false, true),
dcol("cust_id", "int", false, true),
dcol("total", "real", false, false),
],
};
let parent = DiagramTable {
name: "customers".to_string(),
cols: vec![
dcol("region", "text", true, true),
dcol("id", "int", true, true),
dcol("name", "text", false, false),
],
};
let out = layout_text(&child, &parent, 200);
// Both endpoint pairs marked, the bus joins them, and an explicit
// pairing line removes any ambiguity (ADR-0044 §2.4).
assert!(out.contains("cust_region ●"), "child ep 1:\n{out}");
assert!(out.contains("cust_id ●"), "child ep 2:\n{out}");
assert!(
out.contains("(cust_region, cust_id) ▶ customers.(region, id)"),
"pairing line:\n{out}",
);
assert_snapshot!(out);
}
#[test]
fn relationship_diagram_self_referential_shows_two_same_named_boxes() {
// Employee.manager_id → Employee.id (a self-referential FK):
// rendered as two boxes bearing the same name (ADR-0044 §6).
let child = DiagramTable {
name: "Employee".to_string(),
cols: vec![
dcol("id", "serial", true, false),
dcol("manager_id", "int", false, true),
],
};
let parent = DiagramTable {
name: "Employee".to_string(),
cols: vec![
dcol("id", "serial", true, true),
dcol("manager_id", "int", false, false),
],
};
let out = layout_text(&child, &parent, 200);
assert_eq!(out.matches("Employee").count(), 2, "two boxes:\n{out}");
assert!(out.contains("manager_id ●"), "FK endpoint:\n{out}");
assert!(out.contains('▶'), "connector:\n{out}");
assert_snapshot!(out);
}
#[test]
fn render_relationship_diagram_marks_all_compound_endpoints_from_data() {
// The full App-side entry: build_diagram_table must mark BOTH
// paired columns on each side from RelationshipDiagramData.
let blank_rels = || (Vec::new(), Vec::new());
let (r_out, r_in) = blank_rels();
let region = TableDescription {
name: "Region".to_string(),
columns: vec![col("country", Type::Int, true, false), col("code", Type::Int, true, false)],
outbound_relationships: r_out,
inbound_relationships: r_in,
indexes: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
let (c_out, c_in) = blank_rels();
let city = TableDescription {
name: "City".to_string(),
columns: vec![
col("country", Type::Int, false, false),
col("region_code", Type::Int, false, false),
col("name", Type::Text, false, false),
],
outbound_relationships: c_out,
inbound_relationships: c_in,
indexes: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
let data = crate::db::RelationshipDiagramData {
rel: crate::persistence::RelationshipSchema {
name: "city_region".to_string(),
parent_table: "Region".to_string(),
parent_columns: vec!["country".to_string(), "code".to_string()],
child_table: "City".to_string(),
child_columns: vec!["country".to_string(), "region_code".to_string()],
on_delete: ReferentialAction::NoAction,
on_update: ReferentialAction::NoAction,
},
child: city,
parent: region,
};
let text = render_relationship_diagram(&data, 200, Mode::Simple)
.iter()
.map(|l| l.text.clone())
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("region_code ●"), "child endpoint 2:\n{text}");
assert!(text.contains("(PK) ●"), "parent endpoint is PK + marked:\n{text}");
assert!(
text.contains("(country, region_code) ▶ Region.(country, code)"),
"pairing line:\n{text}",
);
}
#[test]
fn render_structure_with_diagrams_replaces_prose_with_compact_diagrams() {
let desc = TableDescription {
@@ -0,0 +1,13 @@
---
source: src/output_render.rs
expression: out
---
┌──────────────────────┐ ┌──────────────────────┐
│ orders │ │ customers │
├───────────────┬──────┤ ├───────────────┬──────┤
│ cust_region ● │ text │n────────┬──────1▶│ region (PK) ● │ text │
│ cust_id ● │ int │n────────┴──────1▶│ id (PK) ● │ int │
│ total │ real │ │ name │ text │
└───────────────┴──────┘ └───────────────┴──────┘
(cust_region, cust_id) ▶ customers.(region, id)
on delete cascade · on update no action
@@ -0,0 +1,11 @@
---
source: src/output_render.rs
expression: out
---
┌───────────────────────┐ ┌─────────────────────┐
│ Employee │ │ Employee │
├──────────────┬────────┤ ├────────────┬────────┤
│ id (PK) │ serial │ ┌──────1▶│ id (PK) ● │ serial │
│ manager_id ● │ int │n────────┘ │ manager_id │ int │
└──────────────┴────────┘ └────────────┴────────┘
on delete cascade · on update no action