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:
@@ -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
@@ -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 {
|
||||
|
||||
+13
@@ -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
|
||||
+11
@@ -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
|
||||
Reference in New Issue
Block a user