diff --git a/docs/adr/0044-relationship-visualization.md b/docs/adr/0044-relationship-visualization.md index 3d7c0c8..1210646 100644 --- a/docs/adr/0044-relationship-visualization.md +++ b/docs/adr/0044-relationship-visualization.md @@ -2,7 +2,13 @@ ## 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 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 - cannot fit (e.g. an extremely narrow pane, or a box wider than the - pane), `show table` emits a one-line pointer per relationship — - ` · run `show relationship ` for the - diagram` — so nothing is silently dropped. `show relationship` - itself always renders (it is the detail view); if its single box is - wider than the pane, ratatui's existing right-edge handling applies - (ADR-0016 §4), no new truncation. +- **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 diff --git a/docs/adr/README.md b/docs/adr/README.md index ff69ab8..1f623e3 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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-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 ~15–20 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

.(a, b) to .(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`) 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 ` (one full diagram), `show table ` (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 ` (one full diagram), `show table ` (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) diff --git a/docs/requirements.md b/docs/requirements.md index 0cac2a5..144b459 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -423,20 +423,24 @@ since ADR-0027.) ## 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, constraints); a selected relationship as two tables joined by a line. - *(Partial, verified 2026-06-07: the **table-structure** half is - done — `output_render.rs:82-180` renders columns / types / - constraints / indexes in a box-drawing table, with relationship - metadata as `References:` / `Referenced by:` prose - (`A.col → B.col`). The **relationship-as-line-art** half — two - tables drawn side by side with a connecting line — is **not - implemented** (deferred per `output_render.rs` §5 OOS-1, ADR - pending). This is the relationship-visualisation piece that has - been repeatedly pushed away; it is the substantive open part of - V1. Selection-nav and the broader journal direction live in V4.)* + *(Done 2026-06-10 — **ADR-0044**. The table-structure half shipped + earlier; the **relationship-as-line-art** half (ADR-0016 OOS-1) now + renders as **Style-A two-table connector diagrams** wherever a + relationship is the subject: `show relationship ` (full + structure boxes), `show table ` and `add`/`drop relationship` + echoes (focal box + compact stacked diagrams). Child-left / + parent-right, `n…1` cardinality, referential actions, bold title + rows; rendered App-side and **width-aware** (side-by-side ↔ vertical + fallback). **Compound** FKs route a shared bus + an explicit + `(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 the output pane, with multiple result tabs supported. *(Partial, verified 2026-06-07: the **table view** is done — diff --git a/src/db.rs b/src/db.rs index b0a6643..cc77bfd 100644 --- a/src/db.rs +++ b/src/db.rs @@ -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 ` still routes here). fn do_show_one( conn: &Connection, kind: crate::dsl::command::ShowListKind, diff --git a/src/output_render.rs b/src/output_render.rs index 01f1efb..24554c4 100644 --- a/src/output_render.rs +++ b/src/output_render.rs @@ -786,38 +786,64 @@ fn body_seg(c: &DiagramCol, label_w: usize, type_w: Option) -> 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 { 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 = Vec::with_capacity(height + 1); + let mut out: Vec = 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 { @@ -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 { 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::>() + .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 { diff --git a/src/snapshots/rdbms_playground__output_render__tests__relationship_diagram_compound_fk_routes_a_bus_and_pairing_line.snap b/src/snapshots/rdbms_playground__output_render__tests__relationship_diagram_compound_fk_routes_a_bus_and_pairing_line.snap new file mode 100644 index 0000000..48d18e9 --- /dev/null +++ b/src/snapshots/rdbms_playground__output_render__tests__relationship_diagram_compound_fk_routes_a_bus_and_pairing_line.snap @@ -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 diff --git a/src/snapshots/rdbms_playground__output_render__tests__relationship_diagram_self_referential_shows_two_same_named_boxes.snap b/src/snapshots/rdbms_playground__output_render__tests__relationship_diagram_self_referential_shows_two_same_named_boxes.snap new file mode 100644 index 0000000..d9de626 --- /dev/null +++ b/src/snapshots/rdbms_playground__output_render__tests__relationship_diagram_self_referential_shows_two_same_named_boxes.snap @@ -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 diff --git a/tests/it/compound_fk.rs b/tests/it/compound_fk.rs index e72ce1e..e8b539f 100644 --- a/tests/it/compound_fk.rs +++ b/tests/it/compound_fk.rs @@ -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)"); }); } + +#[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()], + ); + }); +}