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.
25 KiB
ADR-0044: Relationship visualization (two-table connector diagrams)
Status
Accepted (2026-06-09); implemented 2026-06-10 (commits: cad90ec
show relationship full diagram, a0ee323 show table compact
diagrams, + compound-FK bus routing and self-referential diagrams). A
second /runda DA pass over the implementation confirmed
ADR-compliance, UTF-8/byte-range safety, and edge-case routing; the §3
last-resort helper line was considered and rejected (see §3).
Closes requirements.md V1.
Resolves ADR-0016 OOS-1 ("Relationship visualization — two
structures side-by-side with an arrow; its own ADR; will compose
render_structure"). Partially supersedes ADR-0016 §5 (the
plain-text References: / Referenced by: relationship block) and
extends ADR-0016 §4 and §6 (adds layout width-awareness and
per-span styling). Builds on ADR-0028 (styled output runs),
ADR-0043 (compound, list-based relationship endpoints), ADR-0013
(named 1:n relationships), and the V5/V5a show family. Honours
ADR-0009 (DSL conventions) and ADR-0002 (no engine name in
user-facing strings).
Closes the substantive open half of requirement V1
(docs/requirements.md): "a selected relationship as two tables
joined by a line."
Context
The table-structure half of V1 is done: show table <T> renders a
box-drawn structure table (columns / types / constraints / indexes),
and relationships appear as prose beneath it —
References: / Referenced by: blocks formatted A.col → B.col
(output_render.rs render_structure, per ADR-0016 §5). The same
prose appears in V5a's show relationship <name> detail view.
The open piece is the visual form: drawing a relationship as two tables side by side joined by a connecting line, with cardinality and referential actions. ADR-0016 deliberately deferred this (OOS-1) and pre-sized the structure renderer to compose two of itself. This ADR cashes that in.
Three facts from the current architecture shape the design:
- Rendering is App-side; the worker returns structured data.
(Corrected from the initial draft after direct verification — an
earlier survey had this backwards.) The database worker (
db.rs) returnsTableDescription/DataResult; the App (app.rs) calls theoutput_render.rshelpers to format them intoOutputLines. Verified: everyrender_structure/render_data_tablecall site is inapp.rs(557, 1669, 1732, 1752, 1814, 1678, …); none indb.rs. Width and theme therefore live App-side. The one exception is the V5/V5ashow <kind> [<name>]family:do_show_one/show_listbuild proseVec<String>in the worker fromRelationshipSchema/ index metadata, carrying noTableDescription— soshow relationship <name>is the path that must be restructured (§6). The output buffer is a flatVecDeque<OutputLine>— a historical log of lines, not re-renderable widgets (live re-rendering is V4 territory). - Relationships are list-based (ADR-0043).
RelationshipSchema/RelationshipEndendpoints areVec<String>column lists, positionally paired. Single-column FKs are the one-element case; the diagram must render compound FKs too. - Only 1:n relationships exist. Relationships are declared 1:n (ADR-0013); m:n (requirement C4) is unbuilt, and there is no UNIQUE-target 1:1. So cardinality is always 1 on the parent (referenced) side, n on the child (referencing/FK) side.
The user has chosen the design direction across three decisions (recorded below as made): visual style, where it appears, and multi-relationship layout.
Decision
1. Scope and trigger — diagrams where relationships are the subject
Relationship diagrams replace the prose relationship form on the surfaces where relationships are the subject of the command; no new command or sigil is added (ADR-0009: one sigil, keyword grammar). This is the user-chosen "relationship-relevant" reach (over a global replacement of every structure echo, or a show-commands-only scope — see the DA pass).
Diagram surfaces:
show relationship <name>(V5a) renders one diagram: the parent and child tables as full structure boxes joined by a connector. The canonical base unit.show table <T>keeps T's full structure box at the top (unchanged from ADR-0016 §5), then a Relationships section: one compact connector diagram per relationship, stacked vertically (§4).- Relationship DDL auto-shows — the structure echo after
add 1:n relationship,drop relationship, and a futuremodify relationship(C3a) — render their relationships as compact diagrams (§4), since the user just acted on a relationship.
Prose-retained surfaces (unchanged from ADR-0016 §5):
- Incidental DDL auto-shows — the structure echo after
create table,add/drop/rename/change column,add/drop index— keep the terseReferences:/Referenced by:prose. A simpleadd columnon a heavily-related table should not print a wall of diagrams.
So this partially supersedes ADR-0016 §5: the prose block is
replaced by diagrams on the relationship-subject surfaces and
retained on incidental ones. No information is lost on either —
relationship name, endpoints, cardinality, and on delete /
on update actions all appear in both forms.
Mechanically (§6) this is a render mode on render_structure
(Diagram vs Prose), selected by the calling command; the items/
side panel (ui.rs render_items_panel) is a navigation tree and is
untouched.
A future user-configurable setting (e.g. always-prose / always-diagram / auto-by-width, for small-screen users) is a clean follow-up and is out of scope here (OOS-7) — the per-command default above is the v1 behaviour.
2. The base diagram — Style A (structure + connector)
A relationship diagram is two boxes plus a connector. Reading convention, applied uniformly (this is what makes multi-relationship lists scannable):
- Child (FK holder) on the left, parent (referenced) on the right. The connector arrow always points left → right, in the direction of the reference (child references parent).
- Cardinality sits at the connector ends:
nat the child (left) end,1at the parent (right) end. - Referential actions (
on delete …,on update …) label the connector beneath the line.no actionis shown verbatim (ADR-0009 wording), abbreviated to the action keyword when space is tight.
2.1 Box anatomy — the table name must stand out
Every box (full or compact) carries the table name as a bold title
row separated from the columns by a rule (├─┤), styled in the
table-name theme class (§5). This directly addresses the requirement
that a name never read as just another column row.
Full structure box (used in show relationship): title row, then the
ADR-0016 column/type listing, with key markers (§2.2):
┌──────────────────────┐
│ orders │ ← bold title row (table name)
├──────────────┬───────┤
│ id (PK) │ int │
│ customer_id ●│ int │ ● marks the FK column in this relationship
│ total │ real │
└──────────────┴───────┘
Compact box (used in show table's stacked list): title row, rule,
then only the column(s) participating in this relationship — the
focal table's full structure is already shown above, so the compact
box stays small and the focal table is not redrawn in full N times:
┌──────────────┐
│ orders │ ← bold title row
├──────────────┤
│ customer_id ●│ only the FK column(s) for this relationship
└──────────────┘
2.2 Key markers
- The PK is annotated
(PK)(consistent with the existing structure view's constraint column). - The endpoint columns of this relationship are marked with a
filled dot
●adjacent to the column name — on the child FK column(s) and the parent referenced column(s). The connector attaches at these●rows.
2.3 The connector (single-column)
show relationship Customers_Orders (child orders.customer_id
→ parent customers.id):
orders customers
┌──────────────────────┐ ┌─────────────────────┐
│ orders │ │ customers │
├──────────────┬───────┤ 1 ┌──●│ id (PK) │
│ id (PK) │ int │ │ ├─────────────┬───────┤
│ customer_id ●│ int │ n ───────────┘ │ name │ text │
│ total │ real │ │ email │ text │
└──────────────┴───────┘ └─────────────┴───────┘
on delete cascade · on update no action
Connector routing. The two ● endpoint rows are generally at
different heights. The connector leaves the child box's right edge at
the child-● row, travels horizontally into a gutter channel
between the boxes, jogs vertically to the parent-● row, then enters
the parent box's left edge with an arrowhead. Box tops are aligned.
(Exact glyphs/spacing are pinned by insta snapshots in
implementation; the mockups here are representative, not normative.)
2.4 Compound foreign keys (ADR-0043)
For an n-column FK, the n child ● columns and n parent ● columns
are each listed, and one connector is routed per positional pair.
A summary line states the pairing explicitly so it is unambiguous
even if the routed lines are visually close:
orders customers
┌──────────────────────────┐ ┌──────────────────────┐
│ orders │ 1 ┌●│ region (PK) │
├──────────────┬───────────┤ │ │ id (PK) │
│ cust_region ●│ text │ n ─────────┤ ├────────────┬─────────┤
│ cust_id ●│ int │ n ─────────┘ │ name │ text │
│ … │ … │ └────────────┴─────────┘
└──────────────┴───────────┘
(cust_region, cust_id) ──► customers.(region, id)
on delete cascade · on update no action
3. Width handling and the narrow-terminal fallback
The diagram is rendered once, App-side, at the output-panel width
current when the command runs. The App does not track the
panel width today — note_output_viewport(visible_rows, total_wrapped_rows) records only row counts for scroll math
(verified; App has no width field). This ADR adds a
last_output_width: u16 to App, set from ui.rs where the output
panel's inner.width is already computed (next to the existing
note_output_viewport call), with an 80 default before the first
render. The App-side render path then chooses layout per diagram:
- Side-by-side (§2) when both boxes plus the gutter fit the available width.
- Vertical stack when they do not: parent box above, child box below, connector running downward through a short vertical channel, cardinality and actions labelling it. This keeps a real diagram at any width instead of degrading to prose:
┌──────────────┐
│ orders │
├──────────────┤
│ customer_id ●│ n
└──────────────┘
│ on delete cascade
▼ on update no action
┌──────────────┐
│ customers │
├──────────────┤
│ id (PK)●│ 1
└──────────────┘
- Last-resort helper — considered and rejected (2026-06-10,
implementation). The draft proposed that, when even a single
vertical diagram cannot fit (a box wider than the pane),
show tableemit a one-linerunshow relationship `` pointer per relationship. In implementation we decided against it: the vertical fallback already covers every realistic narrow terminal, and the only remaining case — a box wider than the whole pane — requires an extreme combination (very long identifiers on a tiny terminal) and is handled the same way as all other over-wide output (ratatui's right-edge truncation, ADR-0016 §4). A dedicated pointer would add a code path and a worse result (less information than even a truncated diagram) for a near-unreachable case.show relationshipitself always renders the diagram; if its box is wider than the pane, the same truncation applies — no new truncation logic.
No live reflow. Because the output buffer is a historical line log, resizing the terminal after a diagram is rendered does not reflow it — identical to how every other rendered output behaves today. Reflow-on-resize belongs to V4's re-renderable journal and is explicitly out of scope (OOS-2).
This extends ADR-0016 §4: that ADR added no width-awareness (relying on ratatui truncation); this ADR adds width-awareness for the layout decision only. It still performs no per-cell truncation (ADR-0016 OOS-4 stands).
4. show table multi-relationship layout — stacked compact diagrams
After T's full structure box, a Relationships heading, then one compact diagram (§2.1) per relationship, stacked vertically. Ordering matches the current prose order: outbound (T is the child — "References") first, then inbound (T is the parent — "Referenced by"), each group ordered by relationship name. The child-left/parent-right rule (§2) is applied per diagram, so the focal table appears on the left for its outbound relationships and on the right for its inbound ones — consistent with the reference direction.
orders
┌──────────────┬──────┐
│ orders │ │
├──────────────┼──────┤
│ id (PK) │ int │
│ customer_id │ int │
│ total │ real │
└──────────────┴──────┘
Relationships
┌──────────────┐ n 1 ┌─────────────┐
│ orders │────────────────│ customers │
├──────────────┤ on delete ├─────────────┤
│ customer_id ●│ cascade │ id (PK) ●│
└──────────────┘ └─────────────┘
┌──────────────┐ n 1 ┌─────────────┐
│ order_items │────────────────│ orders │
├──────────────┤ on delete ├─────────────┤
│ order_id ●│ cascade │ id (PK) ●│
└──────────────┘ └─────────────┘
Stacking (rather than a single focal-centred subgraph with fan-out connectors) is chosen because: it never produces crossing lines; it scales to any number of relationships via the pane's existing vertical scroll; each diagram is only two boxes wide, so it fits nearly any terminal; and it keeps the per-diagram width logic of §3 trivial. The cost — the focal table name repeats per diagram — is mitigated by the compact box showing only the participating column(s).
5. Styling
Styling uses the ADR-0028 styled-run mechanism (OutputSpan /
OutputStyleClass on OutputLine), not raw text. New style classes
(theme-defined, legible light/dark per ADR-0016 §6 / NFR-7):
- table name — bold, accent colour (the "stand out" requirement);
- key marker (
●,(PK)) — a distinct accent; - connector (box-drawing line + arrowhead) — muted;
- cardinality (
1/n) — emphasised; - referential action label — muted/secondary.
This extends ADR-0016 §6 (which set up OutputKind::System
styling but no per-element theming) by reusing the per-span
OutputSpan path ADR-0028 later introduced — which is already
produced App-side (output_render.rs:299-332 builds styled runs
for the explain-plan tree; app.rs constructs OutputLines with
styled_runs: Some(..); ui.rs:863 renders them). So no
worker→UI contract change is needed: the new output_render
diagram functions return styled lines directly on the App side
(§6).
6. Implementation
All rendering is App-side (per the corrected Context fact 1), in
output_render.rs (hand-rolled, ADR-0016 §7), returning styled
lines that app.rs pushes as OutputLines with styled_runs set.
No worker available_width and no worker→UI contract change.
- New renderer functions in
output_render.rs, composing the existing box primitives and emitting styled spans (§5):render_relationship_diagram(child, parent, rel, width, full) -> Vec<OutputLine>—fullselects full vs compact boxes; internally decides side-by-side vs vertical (§3) fromwidth; routes the connector(s) including compound pairs (§2.4).- A shared helper for the box title row + key markers.
- The two call paths supply
widthfrom the newApp::last_output_width(§3).
show table <T>(already App-side). The focalTableDescriptionreachesapp.rsand is rendered byrender_structuretoday.render_structureis refactored to emit the focal structure box, then a Relationships section built from the focal description'sRelationshipEnds (which already carry neighbour table name + participating columns + cardinality + actions — enough for the compact box; no neighbour full-structure fetch needed).render_structuregains a relationship render mode (Diagram|Prose); the caller selects it (§1):show tableand the relationship DDL echoes passDiagram, incidental DDL echoes passProse. The generichandle_dsl_success(command, description)(app.rs:1669) already has theCommand, so the mode is a function of the command variant; the incidental-only call sites (app.rs:557/1732/1752/ 1814) passProse.show relationship <name>(must be restructured). Its worker pathdo_show_onecurrently returns proseVec<String>from aRelationshipSchemaonly. To draw the full diagram the App needs both endpointTableDescriptions. The relationship detail reply is upgraded to carry theRelationshipSchemaplus both endpoints'TableDescriptions (the worker already hasdo_describe_table); the App renders the diagram. The "No relationship namedX." not-found line is preserved.- Self-referential FKs (
parent_table == child_table; supported in the grammar): render as two boxes bearing the same table name (child-left copy with the FK column, parent-right copy with the referenced column), connector as usual — clearer than a self-loop glyph in a TUI. Covered by a snapshot test. - Out of these surfaces: the items/side panel (
ui.rs:582render_items_panel) is a navigation tree, not a relationship view, and is unchanged. - Database engine name never appears in any rendered string (ADR-0002).
7. Testing
- Insta snapshots (Tier 2) pin exact rendered output for:
single-column 1:n (
show relationship); compound FK; the narrow-terminal vertical fallback; the last-resort helper line; a self-referential FK; and ashow tablewith both an outbound and an inbound relationship (stacked compact list). - Unit tests (
output_render.rs): the side-by-side-vs-vertical width-threshold decision at boundary widths; connector routing for endpoints at differing row heights; compound-pair routing; key-marker placement. - Tier-3 integration:
show relationship <name>andshow table <T>produce diagram output (not prose) end-to-end through the worker. - Existing tests/code to update (enumerated from a DA grep — the
supersession of ADR-0016 §5 is not abstract):
output_render.rs:121,135— theReferences:/Referenced by:prose-emitting code inrender_structure;output_render.rs:78— the docstring deferral note (OOS-1);output_render.rs:793— unit test asserting"Referenced by:";src/snapshots/…render_structure_with_relationships.snap— the prose snapshot;tests/it/walking_skeleton.rs:477,530— integration asserts on"Referenced by:"(and the comment at 433);src/dsl/command.rs:984— a comment referencing the prose (no behaviour). If the scope fork resolves to "allrender_structurecall sites," the DDL-echo snapshots for create-table / add-column / drop-index / change-column auto-shows also churn — to be re-recorded withcargo insta. No test is deleted to hide a regression; each change is a deliberate format update.
8. Out of scope
- OOS-1. Live reflow-on-resize of already-rendered diagrams — V4's re-renderable journal (requirement V4).
- OOS-2. Whole-database ER diagram / export (requirement V3).
- OOS-3. m:n relationships (requirement C4, unbuilt). Only the existing 1:n form is rendered.
- OOS-4. ASCII fallback for terminals without box-drawing (inherits ADR-0016 OOS-5).
- OOS-5. Per-cell colouring of column data values (ADR-0016 OOS-3 / NFR-5) — unrelated to relationship structure.
- OOS-6. Cell-level truncation with ellipsis (ADR-0016 OOS-4 stands).
- OOS-7. A user-configurable relationship-display setting (always-prose / always-diagram / auto-by-width — useful for small screens). The §1 per-command default is v1; the setting is a clean later follow-up (user-flagged 2026-06-09).
Consequences
- Requirement V1 is fully satisfied: relationships render as
two-table connector diagrams in both
show relationship <name>(full) andshow table <T>(stacked compact), superseding the prose form. output_render.rsgrows a relationship renderer that composes the box primitives exactly as ADR-0016 anticipated; the worker→UI show contract becomes styled-line-based, a small generalisation reusable by other styledshowoutput later.- The worker gains an
available_widthinput forshow— the first width-aware formatting in the codebase. The decision is a snapshot at command time; no resize machinery is introduced. - Compound FKs (ADR-0043) get their first dedicated visualization, with explicit positional-pair labelling.
- The historical-log output model is preserved (no widget/reflow model); V4 remains the home for a re-renderable journal.
- Box-drawing is required (no ASCII fallback), consistent with ADR-0016.
Devil's Advocate / runda pass (2026-06-09)
A planning-time DA pass (empirical, against the code) corrected three foundational errors carried in from an initial code survey, and surfaced one open fork:
- Rendering boundary was inverted (fixed in Context 1 / §6):
render_structure/render_data_tablerun App-side (app.rs), not in the worker. Rendering, width, and theme are all App-side — simpler than the draft assumed. - Width was claimed "already tracked"; it is not (fixed in §3):
note_output_viewportrecords only rows. A newApp::last_output_width(set fromui.rs) is required. show relationship <name>renders prose in the worker (fixed in §6):do_show_onecarries only aRelationshipSchema. The detail reply must be upgraded to include both endpointTableDescriptions for the full diagram.- Styled-line contract was over-stated (fixed in §5): styled runs are already produced App-side (explain plan); no worker→UI contract change.
Resolved fork (user, 2026-06-09): "relationship-relevant" reach.
Diagrams render on the surfaces where the relationship is the subject
(show table, show relationship, relationship DDL echoes);
incidental DDL echoes keep prose (§1) — avoiding a wall of diagrams
after an add column. A future user-configurable display setting is
noted as OOS-7. This also records V1's deliberate scope expansion
beyond its literal "a selected relationship" wording into
multi-relationship surfaces, to be reflected in requirements.md.
All design forks are now resolved and the architecture is corrected. The user accepted this revised ADR on 2026-06-09; status is Accepted and implementation proceeds test-first.