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
+21 -9
View File
@@ -2,7 +2,13 @@
## Status ## Status
Accepted (2026-06-09) 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 Resolves **ADR-0016 OOS-1** ("Relationship visualization — two
structures side-by-side with an arrow; its own ADR; will compose structures side-by-side with an arrow; its own ADR; will compose
@@ -251,14 +257,20 @@ render. The App-side render path then chooses layout per diagram:
└──────────────┘ └──────────────┘
``` ```
- **Last-resort helper.** Only when even a single vertical diagram - **Last-resort helper — considered and rejected (2026-06-10,
cannot fit (e.g. an extremely narrow pane, or a box wider than the implementation).** The draft proposed that, when even a single
pane), `show table` emits a one-line pointer per relationship — *vertical* diagram cannot fit (a box wider than the pane), `show
`<child> → <parent> · run `show relationship <name>` for the table` emit a one-line `run `show relationship <name>`` pointer per
diagram` — so nothing is silently dropped. `show relationship` relationship. In implementation we **decided against it**: the
itself always renders (it is the detail view); if its single box is vertical fallback already covers every realistic narrow terminal,
wider than the pane, ratatui's existing right-edge handling applies and the only remaining case — a box wider than the *whole* pane —
(ADR-0016 §4), no new truncation. 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 **No live reflow.** Because the output buffer is a historical line
log, resizing the terminal *after* a diagram is rendered does not log, resizing the terminal *after* a diagram is rendered does not
+1 -1
View File
@@ -49,4 +49,4 @@ This directory contains the project's ADRs, recorded per
- [ADR-0041 — Copy the output panel to the system clipboard](0041-copy-output-to-clipboard.md) — **Accepted 2026-06-02 (issue #11)**, amends ADR-0003's app-command registry (adds **`copy`** / `copy all` / `copy last`). The friction it removes: filing a bug report meant terminal-selecting the output panel and fighting wrapping/borders. New **app-level command** (sigil-free, both modes): `copy` / `copy all` copy the whole panel; `copy last` copies from the most recent echo line to the end. **Mechanism — OSC 52 *and* native (`arboard`), always both**, because OSC 52 acceptance is undetectable (no terminal ack), so a true "fall back when unsupported" can't be built: emit the OSC 52 escape (no new dep — `base64`+`crossterm`; works over SSH; tmux-passthrough-wrapped via `$TMUX`), then a best-effort native write whose failure is ignored (headless host — OSC 52 carried it); the two carry identical content. **Format — plain text verbatim as rendered** (tags, `✓`/`✗`, box-drawing) joined by `\n`, without viewport padding/wrapping; a drift-lock test pins `OutputLine::plain_text` to `render_output_line`. `arboard` added **`--no-default-features`** (drops the `image` crate; X11-only on Linux — `wayland-data-control` deliberately omitted as it ~doubles the dep tree and OSC 52 covers native-Wayland). Security: write-only, scans clean for arboard's tree (cargo audit / osv-scanner / grype), 1Password-maintained, minimal surface. OOS: Markdown export, selection/range, a keybinding, OSC 52 read, `screen` passthrough - [ADR-0041 — Copy the output panel to the system clipboard](0041-copy-output-to-clipboard.md) — **Accepted 2026-06-02 (issue #11)**, amends ADR-0003's app-command registry (adds **`copy`** / `copy all` / `copy last`). The friction it removes: filing a bug report meant terminal-selecting the output panel and fighting wrapping/borders. New **app-level command** (sigil-free, both modes): `copy` / `copy all` copy the whole panel; `copy last` copies from the most recent echo line to the end. **Mechanism — OSC 52 *and* native (`arboard`), always both**, because OSC 52 acceptance is undetectable (no terminal ack), so a true "fall back when unsupported" can't be built: emit the OSC 52 escape (no new dep — `base64`+`crossterm`; works over SSH; tmux-passthrough-wrapped via `$TMUX`), then a best-effort native write whose failure is ignored (headless host — OSC 52 carried it); the two carry identical content. **Format — plain text verbatim as rendered** (tags, `✓`/`✗`, box-drawing) joined by `\n`, without viewport padding/wrapping; a drift-lock test pins `OutputLine::plain_text` to `render_output_line`. `arboard` added **`--no-default-features`** (drops the `image` crate; X11-only on Linux — `wayland-data-control` deliberately omitted as it ~doubles the dep tree and OSC 52 covers native-Wayland). Security: write-only, scans clean for arboard's tree (cargo audit / osv-scanner / grype), 1Password-maintained, minimal surface. OOS: Markdown export, selection/range, a keybinding, OSC 52 read, `screen` passthrough
- [ADR-0042 — H1a parse-error pedagogy in the grammar-tree era](0042-h1a-parse-error-pedagogy-grammar-tree.md) — **Accepted 2026-06-03.** Continues **H1a** from ADR-0021 against the ADR-0024 grammar tree (ADR-0021's chumsky mechanism is dead). Records the **baseline already shipped** — per-command `usage:` block (38 `parse.usage.*` templates), available-commands fallback, structural "after `…`, expected …" wording, source-derived ident slot labels ("table name"/"column name"), curated `parse.custom.*` near-miss messages, and the ADR-0027/0033/0036 schema-aware `[ERR]` diagnostics — so H1a is *substantially* delivered at the intent level. Defines the remaining work as **(1)** a verified per-command **near-miss matrix** (`tests/typing_surface/` + `tests/it/parse_error_pedagogy.rs`) as the definition of done, test-first; **(2)** **friendlier literal expectation labels** — optional prose glosses on `Word`/`Punct`/`Flag` positions that *add* role context while always keeping the exact literal visible (e.g. "a filter clause: `where …` or `--all-rows`"); **(3)** **advanced-mode SQL** near-miss parity (RETURNING scope, CTE-arity positioning, `CROSS JOIN … ON`, INSERT…SELECT count) — **in scope**, kept distinct from ADR-0019 §OOS-2 which covers advanced-SQL *engine*-error sanitisation, a different layer. Catalog/anchor-phrase discipline (ADR-0019) preserved; no public API change. OOS: I3/I4, spell-correction, multi-error reporting, verbosity-gating the usage block - [ADR-0042 — H1a parse-error pedagogy in the grammar-tree era](0042-h1a-parse-error-pedagogy-grammar-tree.md) — **Accepted 2026-06-03.** Continues **H1a** from ADR-0021 against the ADR-0024 grammar tree (ADR-0021's chumsky mechanism is dead). Records the **baseline already shipped** — per-command `usage:` block (38 `parse.usage.*` templates), available-commands fallback, structural "after `…`, expected …" wording, source-derived ident slot labels ("table name"/"column name"), curated `parse.custom.*` near-miss messages, and the ADR-0027/0033/0036 schema-aware `[ERR]` diagnostics — so H1a is *substantially* delivered at the intent level. Defines the remaining work as **(1)** a verified per-command **near-miss matrix** (`tests/typing_surface/` + `tests/it/parse_error_pedagogy.rs`) as the definition of done, test-first; **(2)** **friendlier literal expectation labels** — optional prose glosses on `Word`/`Punct`/`Flag` positions that *add* role context while always keeping the exact literal visible (e.g. "a filter clause: `where …` or `--all-rows`"); **(3)** **advanced-mode SQL** near-miss parity (RETURNING scope, CTE-arity positioning, `CROSS JOIN … ON`, INSERT…SELECT count) — **in scope**, kept distinct from ADR-0019 §OOS-2 which covers advanced-SQL *engine*-error sanitisation, a different layer. Catalog/anchor-phrase discipline (ADR-0019) preserved; no public API change. OOS: I3/I4, spell-correction, multi-error reporting, verbosity-gating the usage block
- [ADR-0043 — Compound-primary-key foreign-key references (T3)](0043-compound-pk-foreign-key-references.md) — **Accepted + implemented 2026-06-09** (all four forks confirmed at the recommended option: full-PK matching, house-style uniform lists, parenthesized DSL syntax, bare-SQL-FK auto-expansion). Closes `requirements.md` **T3** `[x]` — the relationship model went list-based across six layers (single-column preserved, no migration), DSL `from P.(a,b) to C.(x,y)` + SQL `FOREIGN KEY (a,b) REFERENCES P(x,y)` parse/execute/enforce, 12 tests in `tests/it/compound_fk.rs`. Closes the open leg of `requirements.md` **T3**: a foreign key that *references* a parent's compound primary key. A 2026-06-09 audit found single-column FK woven through ~1520 sites (metadata table, `RelationshipSchema`, `project.yaml` `RawEndpoint`, both grammar surfaces, executor FK-DDL emission, per-column type-compat, display) — earns an ADR, not an inline build. **Decision:** reference the parent's **full** compound PK, matched **positionally** to an equal-length child column list, per-pair `fk_target_type` compat (ADR-0011, element-wise); DSL `from <P>.(a, b) to <C>.(x, y)` (single form unchanged), SQL `FOREIGN KEY (x, y) REFERENCES P(a, b)` (extend the existing one-cap lists; bare table-level FK auto-expands to the parent PK when arities match). **Storage — no migration (back-compat not required, user-confirmed 2026-06-09; no installed base):** the relationship endpoint joins the list convention `project.yaml` *already* uses — `columns: [a, b]` like `primary_key: [id]` and index `columns: [...]` (the endpoint was the lone scalar `column:` holdout); the metadata `TEXT` columns are unchanged and store the list **comma-joined** (`a,b`; the bare name for single — safe because identifiers are `[A-Za-z0-9_]+`). No F3 migrator, no version bump; accepted trade-off is that a pre-change `project.yaml` with relationships won't load (clean cutover). In-memory model goes list-based (`Vec<String>`) through all six layers; the enforced FK is the rebuilt child-table DDL (`FOREIGN KEY (a,b) REFERENCES P(x,y)`), one relationship = one undo step (ADR-0013). Genuine forks escalated: matching policy (full-PK vs subset), storage (house-style uniform lists vs normalized table), DSL syntax (parenthesized vs repeated-dotted), bare-SQL-FK auto-expansion. OOS: subset/non-PK (UNIQUE-targeted) FK references; any single-column behaviour change - [ADR-0043 — Compound-primary-key foreign-key references (T3)](0043-compound-pk-foreign-key-references.md) — **Accepted + implemented 2026-06-09** (all four forks confirmed at the recommended option: full-PK matching, house-style uniform lists, parenthesized DSL syntax, bare-SQL-FK auto-expansion). Closes `requirements.md` **T3** `[x]` — the relationship model went list-based across six layers (single-column preserved, no migration), DSL `from P.(a,b) to C.(x,y)` + SQL `FOREIGN KEY (a,b) REFERENCES P(x,y)` parse/execute/enforce, 12 tests in `tests/it/compound_fk.rs`. Closes the open leg of `requirements.md` **T3**: a foreign key that *references* a parent's compound primary key. A 2026-06-09 audit found single-column FK woven through ~1520 sites (metadata table, `RelationshipSchema`, `project.yaml` `RawEndpoint`, both grammar surfaces, executor FK-DDL emission, per-column type-compat, display) — earns an ADR, not an inline build. **Decision:** reference the parent's **full** compound PK, matched **positionally** to an equal-length child column list, per-pair `fk_target_type` compat (ADR-0011, element-wise); DSL `from <P>.(a, b) to <C>.(x, y)` (single form unchanged), SQL `FOREIGN KEY (x, y) REFERENCES P(a, b)` (extend the existing one-cap lists; bare table-level FK auto-expands to the parent PK when arities match). **Storage — no migration (back-compat not required, user-confirmed 2026-06-09; no installed base):** the relationship endpoint joins the list convention `project.yaml` *already* uses — `columns: [a, b]` like `primary_key: [id]` and index `columns: [...]` (the endpoint was the lone scalar `column:` holdout); the metadata `TEXT` columns are unchanged and store the list **comma-joined** (`a,b`; the bare name for single — safe because identifiers are `[A-Za-z0-9_]+`). No F3 migrator, no version bump; accepted trade-off is that a pre-change `project.yaml` with relationships won't load (clean cutover). In-memory model goes list-based (`Vec<String>`) through all six layers; the enforced FK is the rebuilt child-table DDL (`FOREIGN KEY (a,b) REFERENCES P(x,y)`), one relationship = one undo step (ADR-0013). Genuine forks escalated: matching policy (full-PK vs subset), storage (house-style uniform lists vs normalized table), DSL syntax (parenthesized vs repeated-dotted), bare-SQL-FK auto-expansion. OOS: subset/non-PK (UNIQUE-targeted) FK references; any single-column behaviour change
- [ADR-0044 — Relationship visualization (two-table connector diagrams)](0044-relationship-visualization.md) — **Accepted 2026-06-09** (revised post-`/runda` DA pass). Resolves **ADR-0016 OOS-1** and closes the open half of `requirements.md` **V1** ("a selected relationship as two tables joined by a line"). Renders a relationship as **Style A** (two structure boxes + connector). **Reach = "relationship-relevant"** (user-chosen over global / show-only): diagrams on the surfaces where the relationship is the *subject*`show relationship <name>` (one full diagram), `show table <T>` (T's structure box then a **Relationships** section of **stacked compact** per-relationship diagrams — chosen over a focal-centred subgraph: no crossing lines, scales via scroll, two-boxes-wide fits any terminal), and relationship DDL echoes (`add`/`drop`/`modify relationship`); incidental DDL echoes (`add column`, `drop index`, `change column`, plain `create table`) keep the terse prose, via a `Diagram`|`Prose` render mode on `render_structure`. Reading convention **child(FK)-left / parent-right, arrow →, `n`…`1` cardinality**, applied uniformly; every box gets a **bold title row + rule** so the name can't read as a column. **Compound FKs** (ADR-0043) route one connector per positional pair + an explicit pairing line. **Width-aware** (first in the codebase) but **App-side**: `render_structure`/diagram rendering runs in `app.rs` (the worker only returns `TableDescription`s), a new `App::last_output_width` (set from `ui.rs`) drives side-by-side vs a **vertical-stack** fallback + last-resort "run `show relationship`" pointer; rendered once at command time, **no live reflow** (V4). `show relationship`'s worker path (`do_show_one`, prose-only) is restructured to return both endpoint `TableDescription`s. Styling reuses **ADR-0028** App-side styled runs (new classes: table-name/key/connector/cardinality/action) — no worker→UI contract change. **Partially supersedes ADR-0016 §5** (prose block replaced on relationship-subject surfaces, retained on incidental ones); extends §4 (layout width-awareness, still no cell truncation) and §6 (per-span theming). Tests: insta snapshots (single, compound, vertical fallback, helper line, self-referential, multi-rel `show table`) + width-threshold/routing unit tests + Tier-3 wiring; enumerated prose-fallout updates (`output_render.rs:121/135/793`, the relationships snapshot, `walking_skeleton.rs:477/530`). A `/runda` DA pass corrected three inverted-architecture claims (App-side rendering, untracked width, prose-in-worker show-relationship) before acceptance. OOS: user-configurable display setting (OOS-7), live reflow (V4), whole-DB ER export (V3), m:n (C4), ASCII fallback (ADR-0016 OOS-5) - [ADR-0044 — Relationship visualization (two-table connector diagrams)](0044-relationship-visualization.md) — **Accepted 2026-06-09; implemented 2026-06-10** (closes `requirements.md` V1; second `/runda` pass over the implementation; §3 last-resort helper line considered and rejected). Resolves **ADR-0016 OOS-1** and closes the open half of `requirements.md` **V1** ("a selected relationship as two tables joined by a line"). Renders a relationship as **Style A** (two structure boxes + connector). **Reach = "relationship-relevant"** (user-chosen over global / show-only): diagrams on the surfaces where the relationship is the *subject*`show relationship <name>` (one full diagram), `show table <T>` (T's structure box then a **Relationships** section of **stacked compact** per-relationship diagrams — chosen over a focal-centred subgraph: no crossing lines, scales via scroll, two-boxes-wide fits any terminal), and relationship DDL echoes (`add`/`drop`/`modify relationship`); incidental DDL echoes (`add column`, `drop index`, `change column`, plain `create table`) keep the terse prose, via a `Diagram`|`Prose` render mode on `render_structure`. Reading convention **child(FK)-left / parent-right, arrow →, `n`…`1` cardinality**, applied uniformly; every box gets a **bold title row + rule** so the name can't read as a column. **Compound FKs** (ADR-0043) route one connector per positional pair + an explicit pairing line. **Width-aware** (first in the codebase) but **App-side**: `render_structure`/diagram rendering runs in `app.rs` (the worker only returns `TableDescription`s), a new `App::last_output_width` (set from `ui.rs`) drives side-by-side vs a **vertical-stack** fallback + last-resort "run `show relationship`" pointer; rendered once at command time, **no live reflow** (V4). `show relationship`'s worker path (`do_show_one`, prose-only) is restructured to return both endpoint `TableDescription`s. Styling reuses **ADR-0028** App-side styled runs (new classes: table-name/key/connector/cardinality/action) — no worker→UI contract change. **Partially supersedes ADR-0016 §5** (prose block replaced on relationship-subject surfaces, retained on incidental ones); extends §4 (layout width-awareness, still no cell truncation) and §6 (per-span theming). Tests: insta snapshots (single, compound, vertical fallback, helper line, self-referential, multi-rel `show table`) + width-threshold/routing unit tests + Tier-3 wiring; enumerated prose-fallout updates (`output_render.rs:121/135/793`, the relationships snapshot, `walking_skeleton.rs:477/530`). A `/runda` DA pass corrected three inverted-architecture claims (App-side rendering, untracked width, prose-in-worker show-relationship) before acceptance. OOS: user-configurable display setting (OOS-7), live reflow (V4), whole-DB ER export (V3), m:n (C4), ASCII fallback (ADR-0016 OOS-5)
+15 -11
View File
@@ -423,20 +423,24 @@ since ADR-0027.)
## Visualizations ## Visualizations
- [/] **V1** Single-element views render in the output pane: a - [x] **V1** Single-element views render in the output pane: a
selected table as its structure (columns, types, keys, selected table as its structure (columns, types, keys,
constraints); a selected relationship as two tables joined by constraints); a selected relationship as two tables joined by
a line. a line.
*(Partial, verified 2026-06-07: the **table-structure** half is *(Done 2026-06-10 — **ADR-0044**. The table-structure half shipped
done — `output_render.rs:82-180` renders columns / types / earlier; the **relationship-as-line-art** half (ADR-0016 OOS-1) now
constraints / indexes in a box-drawing table, with relationship renders as **Style-A two-table connector diagrams** wherever a
metadata as `References:` / `Referenced by:` prose relationship is the subject: `show relationship <name>` (full
(`A.col → B.col`). The **relationship-as-line-art** half — two structure boxes), `show table <T>` and `add`/`drop relationship`
tables drawn side by side with a connecting line — is **not echoes (focal box + compact stacked diagrams). Child-left /
implemented** (deferred per `output_render.rs` §5 OOS-1, ADR parent-right, `n…1` cardinality, referential actions, bold title
pending). This is the relationship-visualisation piece that has rows; rendered App-side and **width-aware** (side-by-side ↔ vertical
been repeatedly pushed away; it is the substantive open part of fallback). **Compound** FKs route a shared bus + an explicit
V1. Selection-nav and the broader journal direction live in V4.)* `(a, b) ▶ P.(x, y)` pairing line; **self-referential** FKs draw two
same-named boxes. Incidental DDL echoes keep the prose form (the
"relationship-relevant" reach). The §3 last-resort helper line was
considered and rejected. Two `/runda` passes (design + implementation).
Selection-nav and the broader journal direction remain in V4.)*
- [/] **V2** SQL query results render as a dynamic table view in - [/] **V2** SQL query results render as a dynamic table view in
the output pane, with multiple result tabs supported. the output pane, with multiple result tabs supported.
*(Partial, verified 2026-06-07: the **table view** is done — *(Partial, verified 2026-06-07: the **table view** is done —
+9
View File
@@ -6010,6 +6010,15 @@ fn do_show_list(
/// labelled block, or a friendly "no such item" line. `Tables` is /// labelled block, or a friendly "no such item" line. `Tables` is
/// never routed here (the table singular is `ShowTable`); the /// never routed here (the table singular is `ShowTable`); the
/// defensive arm keeps the match total without a panic. /// 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( fn do_show_one(
conn: &Connection, conn: &Connection,
kind: crate::dsl::command::ShowListKind, kind: crate::dsl::command::ShowListKind,
+212 -35
View File
@@ -786,38 +786,64 @@ fn body_seg(c: &DiagramCol, label_w: usize, type_w: Option<usize>) -> Seg {
seg seg
} }
/// One row of the gutter as a styled segment: routes the connector /// The box-drawing glyph for a bus junction given which directions it
/// from the child endpoint row (`crow`) to the parent endpoint row /// connects (up / down the bus, a child stub from the left, a parent
/// (`prow`), `n` at the child end and `1` at the parent end, a `▶` /// stub to the right).
/// arrowhead into the parent. Handles the straight (same height) and const fn junction(up: bool, down: bool, left: bool, right: bool) -> char {
/// jogged (differing height) cases. match (up, down, left, right) {
fn gutter_seg(i: usize, crow: usize, prow: usize, w: usize) -> Seg { (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 mut cells = vec![' '; w];
let vc = w / 2; let vc = w / 2;
if crow == prow { let on_child = child_rows.contains(&i);
if i == crow { let on_parent = parent_rows.contains(&i);
for c in &mut cells[1..w - 1] {
if on_child {
for c in &mut cells[1..vc] {
*c = '─'; *c = '─';
} }
cells[0] = 'n'; cells[0] = 'n';
cells[w - 2] = '1';
cells[w - 1] = '▶';
} }
} else if i == crow { if on_parent {
for c in &mut cells[..vc] {
*c = '─';
}
cells[vc] = if crow < prow { '┐' } else { '┘' };
cells[0] = 'n';
} else if i == prow {
cells[vc] = if prow > crow { '└' } else { '┌' };
for c in &mut cells[vc + 1..w - 1] { for c in &mut cells[vc + 1..w - 1] {
*c = '─'; *c = '─';
} }
cells[w - 2] = '1'; cells[w - 2] = '1';
cells[w - 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(); let mut seg = Seg::new();
@@ -845,36 +871,48 @@ fn blank_seg(w: usize) -> Seg {
seg seg
} }
/// Two boxes side by side, joined by the gutter connector (ADR-0044 /// The explicit pairing line for a compound FK (ADR-0044 §2.4).
/// §2.3), with the actions line beneath. 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( fn compose_side_by_side(
cb: &BoxLayout, cb: &BoxLayout,
pb: &BoxLayout, pb: &BoxLayout,
crow: usize, pairing: Option<&str>,
prow: usize,
on_delete: &str, on_delete: &str,
on_update: &str, on_update: &str,
) -> Vec<Seg> { ) -> Vec<Seg> {
let height = cb.segs.len().max(pb.segs.len()); let height = cb.segs.len().max(pb.segs.len());
let blank_l = blank_seg(cb.width); let blank_l = blank_seg(cb.width);
let blank_r = blank_seg(pb.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 { for i in 0..height {
let mut seg = Seg::new(); let mut seg = Seg::new();
seg.append(cb.segs.get(i).unwrap_or(&blank_l)); 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)); seg.append(pb.segs.get(i).unwrap_or(&blank_r));
out.push(seg); out.push(seg);
} }
if let Some(p) = pairing {
out.push(pairing_seg(p));
}
out.push(action_seg(on_delete, on_update)); out.push(action_seg(on_delete, on_update));
out out
} }
/// Vertical-stack fallback for narrow terminals (ADR-0044 §3): child /// 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( fn compose_vertical(
cb: &BoxLayout, cb: &BoxLayout,
pb: &BoxLayout, pb: &BoxLayout,
pairing: Option<&str>,
on_delete: &str, on_delete: &str,
on_update: &str, on_update: &str,
) -> Vec<Seg> { ) -> Vec<Seg> {
@@ -883,19 +921,29 @@ fn compose_vertical(
let mut a = Seg::new(); let mut a = Seg::new();
a.push(indent, Conn); a.push(indent, Conn);
a.push("│ n", Card); 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); out.push(a);
let mut b = Seg::new(); let mut b = Seg::new();
b.push(indent, Conn); b.push(indent, Conn);
b.push("▼ 1", Card); 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.push(b);
out.extend(pb.segs.clone()); out.extend(pb.segs.clone());
if let Some(p) = pairing {
out.push(pairing_seg(p));
}
out out
} }
/// Lay out a relationship between two `DiagramTable`s at `width`, /// 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( fn render_relationship_layout(
child: &DiagramTable, child: &DiagramTable,
parent: &DiagramTable, parent: &DiagramTable,
@@ -905,12 +953,30 @@ fn render_relationship_layout(
) -> Vec<Seg> { ) -> Vec<Seg> {
let cb = render_box(child); let cb = render_box(child);
let pb = render_box(parent); let pb = render_box(parent);
let crow = cb.endpoint_rows.first().copied().unwrap_or(0); let child_cols: Vec<&str> = child
let prow = pb.endpoint_rows.first().copied().unwrap_or(0); .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) { 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 { } 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}"); 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] #[test]
fn render_structure_with_diagrams_replaces_prose_with_compact_diagrams() { fn render_structure_with_diagrams_replaces_prose_with_compact_diagrams() {
let desc = TableDescription { 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
+42
View File
@@ -547,3 +547,45 @@ fn compound_fk_partial_pk_reference_is_refused() {
assert!(err.is_err(), "a partial-PK reference must be refused (F-A)"); assert!(err.is_err(), "a partial-PK reference must be refused (F-A)");
}); });
} }
#[test]
fn show_relationship_carries_compound_columns_into_diagram_data() {
// ADR-0044 §2.4: the `show relationship` diagram payload carries
// both paired columns on each side so the renderer can route the
// bus + pairing line.
let (_p, db, _dir) = open_project_db();
let rt = rt();
rt.block_on(async {
seed_compound(&db).await;
db.add_relationship(
Some("city_region".to_string()),
"Region".to_string(),
vec!["country".to_string(), "code".to_string()],
"City".to_string(),
vec!["country".to_string(), "region_code".to_string()],
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
None,
)
.await
.expect("add compound relationship");
let data = db
.show_relationship("city_region".to_string())
.await
.expect("ok")
.expect("found");
// child = FK holder (City), parent = referenced (Region).
assert_eq!(data.child.name, "City");
assert_eq!(data.parent.name, "Region");
assert_eq!(
data.rel.child_columns,
vec!["country".to_string(), "region_code".to_string()],
);
assert_eq!(
data.rel.parent_columns,
vec!["country".to_string(), "code".to_string()],
);
});
}