Files
rdbms-playground/docs/adr/0044-relationship-visualization.md
T
claude@clouddev1 8ac3537df0 feat(render): incidental-DDL confirmations show structure only, no relationships (#28)
Per ADR-0050 (closing issue #28): the confirmation echo after an
incidental structural edit — create table, add/drop/rename/change
column, add/drop index — now renders the structure only (header +
column box + indexes + constraints) and no longer appends the
References:/Referenced by: relationship block.

Rationale: a confirmation reports the change just made, not the
table's relationships, which the user didn't touch. Relationship info
is still one `show table <T>` away, and the relationship-subject
surfaces (show table, add/drop relationship) keep their ADR-0044
diagrams unchanged.

Scope is all incidental DDL (user-confirmed). Mechanism: drop the
relationship-block call from render_structure (all its callers are
incidental DDL); the handle_dsl_success diagram-vs-structure routing
is unchanged. The orphaned relationship_prose_lines + cols_disp
helpers are deleted (the prose format survives in ADR-0016 §5 + git
history for a future OOS-7 always-prose setting).

ADR-0050 supersedes ADR-0044 §1's incidental-DDL prose clause and the
relationship-block half of ADR-0016 §5 (both annotated). Tests: prose-
presence unit test + snapshot removed; new unit test locks structure-
only with inbound+outbound relationships present; the misnamed add-
column integration test inverted + renamed. 2458 pass / 0 fail / 0
skip, clippy clean.
2026-06-12 22:45:18 +00:00

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:

  1. 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) returns TableDescription / DataResult; the App (app.rs) calls the output_render.rs helpers to format them into OutputLines. Verified: every render_structure / render_data_table call site is in app.rs (557, 1669, 1732, 1752, 1814, 1678, …); none in db.rs. Width and theme therefore live App-side. The one exception is the V5/V5a show <kind> [<name>] family: do_show_one / show_list build prose Vec<String> in the worker from RelationshipSchema / index metadata, carrying no TableDescription — so show relationship <name> is the path that must be restructured (§6). The output buffer is a flat VecDeque<OutputLine> — a historical log of lines, not re-renderable widgets (live re-rendering is V4 territory).
  2. Relationships are list-based (ADR-0043). RelationshipSchema /RelationshipEnd endpoints are Vec<String> column lists, positionally paired. Single-column FKs are the one-element case; the diagram must render compound FKs too.
  3. 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 future modify 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 terse References: / Referenced by: prose. A simple add column on a heavily-related table should not print a wall of diagrams. (Superseded 2026-06-12 by ADR-0050 (issue #28): these incidental DDL echoes now render structure only — no relationship block at all, neither prose nor diagram. The prose renderer was deleted. The diagram surfaces below are unchanged.)

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: n at the child (left) end, 1 at the parent (right) end.
  • Referential actions (on delete …, on update …) label the connector beneath the line. no action is 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 table emit a one-line run show 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 relationship itself 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>full selects full vs compact boxes; internally decides side-by-side vs vertical (§3) from width; routes the connector(s) including compound pairs (§2.4).
    • A shared helper for the box title row + key markers.
    • The two call paths supply width from the new App::last_output_width (§3).
  • show table <T> (already App-side). The focal TableDescription reaches app.rs and is rendered by render_structure today. render_structure is refactored to emit the focal structure box, then a Relationships section built from the focal description's RelationshipEnds (which already carry neighbour table name + participating columns + cardinality + actions — enough for the compact box; no neighbour full-structure fetch needed). render_structure gains a relationship render mode (Diagram | Prose); the caller selects it (§1): show table and the relationship DDL echoes pass Diagram, incidental DDL echoes pass Prose. The generic handle_dsl_success(command, description) (app.rs:1669) already has the Command, so the mode is a function of the command variant; the incidental-only call sites (app.rs:557/1732/1752/ 1814) pass Prose.
  • show relationship <name> (must be restructured). Its worker path do_show_one currently returns prose Vec<String> from a RelationshipSchema only. To draw the full diagram the App needs both endpoint TableDescriptions. The relationship detail reply is upgraded to carry the RelationshipSchema plus both endpoints' TableDescriptions (the worker already has do_describe_table); the App renders the diagram. The "No relationship named X." 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:582 render_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 a show table with 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> and show 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 — the References: / Referenced by: prose-emitting code in render_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 "all render_structure call sites," the DDL-echo snapshots for create-table / add-column / drop-index / change-column auto-shows also churn — to be re-recorded with cargo 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) and show table <T> (stacked compact), superseding the prose form.
  • output_render.rs grows 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 styled show output later.
  • The worker gains an available_width input for show — 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:

  1. Rendering boundary was inverted (fixed in Context 1 / §6): render_structure / render_data_table run App-side (app.rs), not in the worker. Rendering, width, and theme are all App-side — simpler than the draft assumed.
  2. Width was claimed "already tracked"; it is not (fixed in §3): note_output_viewport records only rows. A new App::last_output_width (set from ui.rs) is required.
  3. show relationship <name> renders prose in the worker (fixed in §6): do_show_one carries only a RelationshipSchema. The detail reply must be upgraded to include both endpoint TableDescriptions for the full diagram.
  4. 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.