diff --git a/docs/adr/0044-relationship-visualization.md b/docs/adr/0044-relationship-visualization.md new file mode 100644 index 0000000..1210646 --- /dev/null +++ b/docs/adr/0044-relationship-visualization.md @@ -0,0 +1,504 @@ +# 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 ` 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 ` 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 + `OutputLine`s. 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 []` family: `do_show_one` / `show_list` build + prose `Vec` **in the worker** from `RelationshipSchema` / + index metadata, carrying *no* `TableDescription` — so + `show relationship ` is the path that must be restructured + (§6). The output buffer is a flat `VecDeque` — 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` 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 `** (V5a) renders **one** diagram: the + parent and child tables as **full structure boxes** joined by a + connector. The canonical base unit. +- **`show table `** 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. + +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 `OutputLine`s 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 `OutputLine`s 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` — `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 ` (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 `RelationshipEnd`s (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 ` (must be restructured).** Its + worker path `do_show_one` currently returns prose `Vec` + from a `RelationshipSchema` only. To draw the **full** diagram the + App needs **both** endpoint `TableDescription`s. The relationship + detail reply is upgraded to carry the `RelationshipSchema` plus + both endpoints' `TableDescription`s (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 ` and + `show table ` 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 ` + (full) and `show table ` (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 ` 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 + `TableDescription`s 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. diff --git a/docs/adr/README.md b/docs/adr/README.md index 7a5c469..46abe13 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -56,3 +56,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; 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/handoff/20260610-handoff-60.md b/docs/handoff/20260610-handoff-60.md new file mode 100644 index 0000000..a6615aa --- /dev/null +++ b/docs/handoff/20260610-handoff-60.md @@ -0,0 +1,211 @@ +# Session handoff — 2026-06-10 (60) + +Sixtieth handover. Continues from handoff-59 (tracking reconciliation ++ V5/H3/V5a sweep + T3 compound-PK FK). This session did **three** +things: (1) verified the **Claude Code 2.1.170** upgrade is healthy; +(2) finished the **GitHub→Gitea migration cleanup** and added `tea` +issue-tracking conventions; (3) the big one — **completed requirement +V1, relationship visualization**, designed in **ADR-0044** and +implemented end-to-end with two `/runda` passes. + +## §1. State at handoff + +**Branch:** `main`. **HEAD `0a34303`.** 5 commits this session +(`b17148b` → `0a34303`); push is the user's step. + +**Tests: 2207 passing / 0 failing / 1 ignored** (lib 1586, it 429, +typing_surface_matrix 192; the 1 ignored is the long-standing +doc-test). **Clippy clean** (nursery, all targets). +14 over the +handoff-59 baseline of 2193. + +This session's commits: +``` +0a34303 feat: compound-FK bus routing + complete V1 relationship visualization (ADR-0044) +a0ee323 feat: show table renders relationships as compact diagrams (ADR-0044) +cad90ec feat: show relationship renders a styled two-table diagram (ADR-0044) +bb02dfb docs: ADR-0044 relationship visualization (V1); accepted +b17148b docs: scrub GitHub-specifics after Gitea migration; add tea issue conventions +``` + +## §2. Repo migration: GitHub → Gitea (commit `b17148b`) + +The repo moved off GitHub to a self-hosted **Gitea** at +`git.lazyeval.net` (`oli/rdbms-playground`). `tea` auto-detects this +repo correctly off the remote **even though the machine's default +`tea` login is a different host** (`git.oliversturm.com`) — verified. + +- **Durable GitHub-specifics scrubbed:** `Cargo.toml` `repository` + URL; `requirements.md` backlog note ("now tracked as Gitea + issues"); **ADR-0001 Amendment 1** reopens the prebuilt-binary + distribution channel (was "GitHub releases") as an undecided choice + for a future distribution ADR (the Decision text was *not* + rewritten, per supersede-don't-rewrite). +- **Left as historical:** all `docs/handoff/*.md` (append-only log). +- **`CLAUDE.md` gained** an *Issue tracking* working-style bullet + + an *Issue tracking — Gitea via `tea`* section (adapted from another + project; repo coordinates corrected, `tea` gotchas kept). **Working + method now: file bugs/enhancements as Gitea issues, cross-reference + in commits/handoffs; `requirements.md` + ADRs remain the source of + truth for scope/decisions; a change to a decided area still earns an + ADR.** No heavyweight planning workflow (we're near completion of + the initial requirements). +- No open Gitea issues; the 18-issue campaign backlog (#1–#18) is all + closed. V1 lives in `requirements.md`, not an issue. + +## §3. V1 — relationship visualization (the big one) + +**ADR-0044** (`docs/adr/0044-relationship-visualization.md`, Accepted +2026-06-09, **implemented 2026-06-10**). Resolves **ADR-0016 OOS-1** +and closes `requirements.md` **V1** (`[x]`). The +relationship-as-line-art half that had been "repeatedly pushed away" +now renders as **Style-A two-table connector diagrams**. + +**Design forks (all user-chosen):** +- **Style A** (two structure boxes + connector) over a compact key + card or crow's-foot ER. +- **Reach = "relationship-relevant"** (over global / show-only): + diagrams where the relationship is the *subject* — + `show relationship `, `show table `, and + `add`/`drop relationship` echoes; incidental DDL echoes (add column, + drop index, change column, plain create table) keep the prose + `References:` / `Referenced by:` form. +- **`show table` layout:** focal structure box, then a + **Relationships** section of **stacked compact** per-relationship + diagrams (over a focal-centred subgraph — no crossing lines, scales + via scroll, two-boxes-wide fits any terminal). + +**Visual conventions:** child (FK holder) on the **left**, parent +(referenced) on the **right**, arrow → (child references parent), +**`n` … `1`** cardinality, referential actions beneath, and a +**bold title row + rule** on every box so a table name can't read as +a column. Compound FKs route a shared **bus** (each endpoint stub +merges into a vertical channel that splits to the paired endpoints) +plus an explicit `(a, b) ▶ P.(x, y)` **pairing line**. +Self-referential FKs draw two same-named boxes. **Width-aware**: +side-by-side when it fits, else a **vertical stack** fallback. + +**Three implementation increments:** +- `cad90ec` — `show relationship ` full diagram (both tables as + full structure boxes). +- `a0ee323` — `show table` compact stacked diagrams + the + `Diagram|Prose` render mode. +- `0a34303` — compound-FK bus routing + pairing line, self-referential + diagrams, V1 → `[x]`. + +**Two `/runda` passes.** The design pass (pre-build) **caught an +inverted-architecture assumption** carried in from a code survey +(rendering is App-side, not worker-side; width was claimed "already +tracked" but wasn't; `show relationship` built prose in the worker). +The implementation pass confirmed ADR-compliance, UTF-8/byte-range +safety, and edge-case routing, and added a compound-from-data glue +test. The §3 **last-resort helper line was considered and rejected** +(vertical fallback + ratatui truncation cover all realistic cases). + +## §4. Key implementation facts (read before touching the renderer) + +- **Rendering is App-side.** `output_render.rs` helpers + (`render_structure`, `render_data_table`, and the new diagram + functions) are called from **`app.rs`**, never `db.rs`. The worker + returns structured data (`TableDescription`); the App formats it. + *(An Explore survey got this backwards — verify architecture claims + against the code, not a summary.)* +- **The diagram renderer** lives in `output_render.rs` under the + `// ── Relationship visualization (ADR-0044)` banner: a styled `Seg` + engine (text + `OutputSpan` runs that compose by concatenation with + offset-shifting); `render_box` (full or compact box with a title + row); `gutter_seg` + `junction` (the bus connector — routes **all** + endpoint pairs, reduces exactly to the single-column jog); + `compose_side_by_side` / `compose_vertical`; + `render_relationship_layout` (width dispatch + pairing line); + `render_relationship_diagram` (the `show relationship` entry, builds + full `DiagramTable`s from `RelationshipDiagramData`); + `render_structure_with_diagrams` (the `show table` entry: focal box + + compact diagrams; `render_structure` was refactored into section + helpers — `structure_box_lines` / `relationship_prose_lines` / + `index_lines` / `constraint_lines` — with **byte-identical** prose + output so the old snapshots held). +- **`show relationship` worker path:** `db.rs` + `RelationshipDiagramData` (rel + both endpoint `TableDescription`s) + + `Database::show_relationship` + `do_show_relationship`; runtime + `CommandOutcome::ShowRelationship` (boxed — two `TableDescription`s + dwarf the other variants) reroutes a named relationship before the + prose `show_list` fallback; event `DslShowRelationshipSucceeded`; + app `handle_dsl_show_relationship_success`. +- **The Diagram/Prose split** is in `handle_dsl_success`: it renders + diagrams for `ShowTable | AddRelationship | DropRelationship`, prose + otherwise. +- **Width:** new `App::last_output_width` (default `80`), set from + `ui.rs` `render_output_panel` next to `note_output_viewport`. + Snapshot at command time — **no live reflow** (that's V4). +- **Styling:** four new `OutputStyleClass` variants + (`DiagramTableName` / `DiagramKey` / `DiagramCardinality` / + `DiagramConnector`), mapped in `ui.rs::output_span_style` to + **existing** theme colours (no new `Theme` fields). `Seg` only ever + pushes whole strings/chars, so styled-run byte ranges are always + valid UTF-8 boundaries (exercised live by the `add relationship` + `rendered_text` test through `ui.rs`'s `text[start..end]` slice). +- **`do_show_one`'s relationship prose branch is now dead-for-users** + (the reroute supersedes it) but **retained** — reachable via the + `Database::show_list` worker API, covered by a worker test, and a + candidate text fallback for a future non-visual display option (cf. + ADR-0044 **OOS-7** relationship-display setting). Documented in + `db.rs`. +- **Two bugs the tests caught** (both in compact boxes, which the + single-relationship full-box tests didn't exercise): an eager + `widths[1]` index panic (`then_some` → `then`), and body-cell + padding under title-widening (pass the widened `widths[0]`, not the + pre-widening `label_w`). + +## §5. Remaining open landscape + +**Still `[/]` partial / `[~]` / larger (unchanged from handoff-59):** +- **V2 / S3** multi-result tabs — output-model redesign. +- **V3** whole-DB ER export; **V4** scrollable journal + Markdown + (the home for diagram live-reflow, OOS-1 here). +- **A1** app-commands — blocked on `seed` (SD1) + `hint` (H2). +- **DOC1** reference docs; **X1** logging density. + +**`[ ]` not started:** H2 `hint`, SD1 `seed`, C4 m:n, B3 +query-timeout, I1 multi-line, I1b readline, I5 cancellation, **TT5 +CI** (now Gitea Actions, ties into ADR-0001's reopened distribution +question), TT4 PTY (spec-only), D1–D3 distribution, NFR-1…7. + +**ADR-0044 OOS for later:** OOS-7 user-configurable +relationship-display setting (always-prose / always-diagram / +auto-by-width); compound display polish if needed. + +## §6. Next job — candidates + +No forced next step. By readiness: +1. **X1 logging** — mechanical, no ADR; brings instrumentation to the + `CLAUDE.md` "log liberally" bar (~25 `tracing` sites today). +2. **TT5 CI** — test infra solid (2207 green); no pipeline. Now + **Gitea Actions / Woodpecker**, not GitHub Actions — a fresh + decision tied to today's migration + ADR-0001's distribution + question. +3. **T3 residuals** (ADR-0043 §4) — two messaging-only polish items + (inline-FK arity error wording; compound-FK-violation friendly + error names only the first pair). +4. **V2/S3 multi-result tabs** or **V4 journal** — larger, + design-first (own ADR). + +## §7. How to take over + +1. Read handoffs 58 → 59 → 60, then `CLAUDE.md` (now with the + *Issue tracking — Gitea via `tea`* section), `docs/requirements.md` + (V1 now `[x]`), `docs/adr/README.md`. +2. **For relationship diagrams: read ADR-0044**, then the + `// ── Relationship visualization` block in `src/output_render.rs` + (§4 above maps the functions). +3. **Gitea/`tea`:** plain `tea issues` works here (auto-detects + `git.lazyeval.net`); the gotchas section in `CLAUDE.md` matters + (stdin-hang → `< /dev/null`; multiline bodies via temp file; the + display blind-spot for milestones/comments). +4. Codebase on `main` at `0a34303`, clean, 5 commits unpushed. +5. Process pins that paid off this session: **verify architecture + claims against the code, not a survey** (the §3 inversion); + **`/runda` after design AND after implementation** (both found real + things); **tests on the *compact* path caught bugs the full-box + path missed** — exercise every variant; **escalate genuine forks** + (every V1 design choice was the user's). Commits user-confirmed, + append-only, no AI attribution. 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/app.rs b/src/app.rs index 02c890b..75ec9d9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -76,6 +76,19 @@ pub enum OutputStyleClass { /// every `[client-side]` category-3 prose note (ADR-0038 §6). /// Resolves to `theme.muted`. Hint, + /// A relationship-diagram box's title row — the table name + /// (ADR-0044 §2.1). Bold accent so it cannot read as a column. + DiagramTableName, + /// A relationship-diagram key marker — `(PK)` / `●` on the + /// participating columns (ADR-0044 §2.2). + DiagramKey, + /// A relationship-diagram cardinality label — `1` / `n` + /// (ADR-0044 §2). + DiagramCardinality, + /// A relationship-diagram connector — box-drawing line, elbows + /// and arrowhead between the two boxes (ADR-0044 §2.3). Muted so + /// the structure, not the wiring, leads. + DiagramConnector, } /// A styled span of an output line: a byte range over the @@ -268,6 +281,11 @@ pub struct App { /// logical OutputLines. Required for accurate scroll capping /// when long lines wrap to multiple display rows. pub last_output_total_wrapped: usize, + /// The most recent inner width (in columns) of the output panel, + /// recorded by the renderer (ADR-0044 §3). Drives the relationship + /// diagram's side-by-side vs vertical layout choice. Defaults to + /// `80` until the first render measures the real width. + pub last_output_width: u16, /// Prettified display name of the currently-open project, /// rendered in the status bar (P-NAME-3, ADR-0015 §2). `None` /// during very-early startup before the runtime has opened a @@ -432,6 +450,7 @@ impl App { output_scroll: 0, last_output_visible: 0, last_output_total_wrapped: 0, + last_output_width: 80, project_name: None, project_is_temp: false, fatal_message: None, @@ -614,6 +633,10 @@ impl App { } Vec::new() } + AppEvent::DslShowRelationshipSucceeded { command, data } => { + self.handle_dsl_show_relationship_success(&command, data.as_ref()); + Vec::new() + } AppEvent::DslInsertSucceeded { command, result } => { self.handle_dsl_insert_success(&command, &result); Vec::new() @@ -1666,8 +1689,28 @@ impl App { fn handle_dsl_success(&mut self, command: &Command, description: Option) { self.note_ok_summary(command); if let Some(desc) = description.as_ref() { - for line in crate::output_render::render_structure(desc) { - self.note_system(line); + // ADR-0044 §1 "relationship-relevant" reach: when a + // relationship is the subject of the command (`show table`, + // `add`/`drop relationship`), render the table's + // relationships as compact diagrams; every other DDL echo + // keeps the prose `References:` / `Referenced by:` form. + if matches!( + command, + Command::ShowTable { .. } + | Command::AddRelationship { .. } + | Command::DropRelationship { .. } + ) { + for line in crate::output_render::render_structure_with_diagrams( + desc, + self.last_output_width, + self.mode, + ) { + self.push_output(line); + } + } else { + for line in crate::output_render::render_structure(desc) { + self.note_system(line); + } } } self.current_table = description; @@ -1694,6 +1737,35 @@ impl App { } } + /// `show relationship ` (ADR-0044): render the relationship + /// as a styled two-table diagram, App-side, sized to the current + /// output-panel width. `None` is the friendly not-found line. + fn handle_dsl_show_relationship_success( + &mut self, + command: &Command, + data: Option<&crate::db::RelationshipDiagramData>, + ) { + self.note_ok_summary(command); + match data { + Some(data) => { + for line in crate::output_render::render_relationship_diagram( + data, + self.last_output_width, + self.mode, + ) { + self.push_output(line); + } + } + None => { + let name = match command { + Command::ShowList { name: Some(n), .. } => n.as_str(), + _ => "", + }; + self.note_system(format!("No relationship named `{name}`.")); + } + } + } + fn handle_dsl_insert_success(&mut self, command: &Command, result: &InsertResult) { self.note_ok_summary(command); self.note_system(crate::t!("ok.rows_inserted", count = result.rows_affected)); diff --git a/src/db.rs b/src/db.rs index 4adc288..cc77bfd 100644 --- a/src/db.rs +++ b/src/db.rs @@ -80,6 +80,21 @@ pub struct TableDescription { pub check_constraints: Vec, } +/// Structured payload for rendering one relationship's diagram. +/// +/// ADR-0044: the relationship plus both endpoint table structures. +/// Built worker-side; rendered **App-side** (like `QueryPlan`) so the +/// diagram can be width-aware and styled. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RelationshipDiagramData { + /// The relationship itself (endpoints + referential actions). + pub rel: crate::persistence::RelationshipSchema, + /// FK-holder (the `n` side), drawn on the left. + pub child: TableDescription, + /// Referenced table (the `1` side), drawn on the right. + pub parent: TableDescription, +} + /// One user-created index on a table (ADR-0025). /// /// Read live from the engine's native catalog @@ -566,6 +581,13 @@ enum Request { name: Option, reply: oneshot::Sender, DbError>>, }, + /// Structured data to render one relationship's diagram (ADR-0044 + /// §6): the relationship + both endpoint table structures, or + /// `None` if no relationship by that name exists. + ShowRelationship { + name: String, + reply: oneshot::Sender, DbError>>, + }, DescribeTable { name: String, source: Option, @@ -1341,6 +1363,18 @@ impl Database { recv.await.map_err(|_| DbError::WorkerGone)? } + /// Structured data to render one relationship's diagram (ADR-0044): + /// the relationship + both endpoint table structures, or `None` if + /// no relationship by that name exists. + pub async fn show_relationship( + &self, + name: String, + ) -> Result, DbError> { + let (reply, recv) = oneshot::channel(); + self.send(Request::ShowRelationship { name, reply }).await?; + recv.await.map_err(|_| DbError::WorkerGone)? + } + pub async fn describe_table( &self, name: String, @@ -2272,6 +2306,9 @@ fn handle_request( Request::ShowList { kind, name, reply } => { let _ = reply.send(do_show_list(conn, kind, name.as_deref())); } + Request::ShowRelationship { name, reply } => { + let _ = reply.send(do_show_relationship(conn, &name)); + } Request::DescribeTable { name, source, @@ -5870,6 +5907,25 @@ fn do_list_tables(conn: &Connection) -> Result, DbError> { Ok(out) } +/// Structured data to render one relationship's diagram (ADR-0044): +/// find the named relationship, then describe both endpoint tables. +/// `Ok(None)` when no relationship by that name exists (the App shows +/// a friendly not-found line). +fn do_show_relationship( + conn: &Connection, + name: &str, +) -> Result, DbError> { + let Some(rel) = read_all_relationships(conn)? + .into_iter() + .find(|r| r.name == name) + else { + return Ok(None); + }; + let child = do_describe_table(conn, &rel.child_table)?; + let parent = do_describe_table(conn, &rel.parent_table)?; + Ok(Some(RelationshipDiagramData { rel, child, parent })) +} + /// Pre-formatted display lines for the `show ` list commands /// (V5). A count header followed by one indented item per line, or a /// single friendly "none yet" line for an empty collection. Reuses @@ -5954,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/event.rs b/src/event.rs index 6bde025..293dacf 100644 --- a/src/event.rs +++ b/src/event.rs @@ -9,7 +9,8 @@ use crossterm::event::KeyEvent; use crate::db::{ AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult, - DropColumnResult, InsertResult, QueryPlan, TableDescription, UpdateResult, + DropColumnResult, InsertResult, QueryPlan, RelationshipDiagramData, TableDescription, + UpdateResult, }; use crate::dsl::Command; @@ -76,6 +77,12 @@ pub enum AppEvent { /// A `show ` list command (V5) — carries pre-formatted /// display lines (tables / relationships / indexes). DslShowListSucceeded { command: Command, lines: Vec }, + /// `show relationship ` (ADR-0044) — structured data for the + /// diagram, rendered App-side; `None` when no such relationship. + DslShowRelationshipSucceeded { + command: Command, + data: Option, + }, DslInsertSucceeded { command: Command, result: InsertResult, diff --git a/src/output_render.rs b/src/output_render.rs index a3251b0..24554c4 100644 --- a/src/output_render.rs +++ b/src/output_render.rs @@ -90,9 +90,18 @@ fn cols_disp(cols: &[String]) -> String { #[must_use] pub fn render_structure(desc: &TableDescription) -> Vec { - let mut out: Vec = Vec::new(); - out.push(desc.name.clone()); + let mut out = structure_box_lines(desc); + out.extend(relationship_prose_lines(desc)); + out.extend(index_lines(desc)); + out.extend(constraint_lines(desc)); + out +} +/// The table-name header line + the box-drawn column / type / +/// constraint table. Shared by the prose [`render_structure`] and the +/// diagram [`render_structure_with_diagrams`] (ADR-0044). +fn structure_box_lines(desc: &TableDescription) -> Vec { + let mut out: Vec = vec![desc.name.clone()]; let header_cells = vec![ "Name".to_string(), "Type".to_string(), @@ -101,22 +110,18 @@ pub fn render_structure(desc: &TableDescription) -> Vec { let body: Vec> = desc .columns .iter() - .map(|c| { - vec![ - c.name.clone(), - type_display(c), - constraints_display(c), - ] - }) + .map(|c| vec![c.name.clone(), type_display(c), constraints_display(c)]) .collect(); - // Type column gets the same numeric/text rule as data - // columns by virtue of consistency, but every entry is - // a keyword string ("text", "serial", …) so left-align - // is correct in every case. Constraints are similarly - // textual. + // Every cell is a keyword/text string, so left-align throughout. let alignments = vec![Alignment::Left, Alignment::Left, Alignment::Left]; out.extend(render_table(&header_cells, &body, &alignments)); + out +} +/// The `References:` / `Referenced by:` prose blocks (ADR-0016 §5), +/// retained for the incidental DDL echoes (ADR-0044 §1). +fn relationship_prose_lines(desc: &TableDescription) -> Vec { + let mut out: Vec = Vec::new(); if !desc.outbound_relationships.is_empty() { out.push("References:".to_string()); for r in &desc.outbound_relationships { @@ -145,11 +150,14 @@ pub fn render_structure(desc: &TableDescription) -> Vec { )); } } + out +} - // Indexes section (ADR-0025), shown only when the table - // carries at least one user-created index. A UNIQUE index is - // marked `[unique]` so a learner can tell a uniqueness-enforcing - // index from a performance-only one (ADR-0035 §4d). +/// Indexes section (ADR-0025), only when the table carries a +/// user-created index. A UNIQUE index is marked `[unique]` (ADR-0035 +/// §4d). +fn index_lines(desc: &TableDescription) -> Vec { + let mut out: Vec = Vec::new(); if !desc.indexes.is_empty() { out.push("Indexes:".to_string()); for index in &desc.indexes { @@ -161,17 +169,18 @@ pub fn render_structure(desc: &TableDescription) -> Vec { )); } } + out +} - // Table-level constraints (ADR-0035 §4i b): composite `UNIQUE (a, b)` - // and table `CHECK (…)` constraints. Single-column UNIQUE / NOT NULL / - // PK / column-level CHECK already show in the per-column "Constraints" - // column above; this section is the table-level constraints that span - // columns or stand alone. A named CHECK shows its name. +/// Table-level constraints (ADR-0035 §4i b): composite `UNIQUE (a, b)` +/// and table `CHECK (…)`. Column-level constraints already show in the +/// per-column "Constraints" column; this is the multi-column / named +/// set, each with its addressable name where it has one. +fn constraint_lines(desc: &TableDescription) -> Vec { + let mut out: Vec = Vec::new(); if !desc.unique_constraints.is_empty() || !desc.check_constraints.is_empty() { out.push("Table constraints:".to_string()); for cols in &desc.unique_constraints { - // Annotate with the derived, addressable name (ADR-0035 - // Amendment 1) so the user can `drop constraint `. out.push(format!( " {}: unique ({})", crate::db::unique_constraint_name(cols), @@ -185,7 +194,6 @@ pub fn render_structure(desc: &TableDescription) -> Vec { } } } - out } @@ -484,6 +492,9 @@ enum BorderRow { Top, HeaderUnderline, Bottom, + /// The rule **under a full-width title row** that introduces the + /// body's column split (ADR-0044 §2.1): `├──┬──┤`. + TitleUnderline, } fn border_row(widths: &[usize], kind: BorderRow) -> String { @@ -491,6 +502,7 @@ fn border_row(widths: &[usize], kind: BorderRow) -> String { BorderRow::Top => ('┌', '┬', '┐'), BorderRow::HeaderUnderline => ('├', '┼', '┤'), BorderRow::Bottom => ('└', '┴', '┘'), + BorderRow::TitleUnderline => ('├', '┬', '┤'), }; let mut s = String::new(); s.push(left); @@ -540,6 +552,565 @@ fn content_row(cells: &[String], widths: &[usize], alignments: &[Alignment]) -> s } +// ── Relationship visualization (ADR-0044) ────────────────────────── +// +// A relationship diagram draws two table boxes joined by a connector: +// child (FK holder) on the left, parent (referenced) on the right, the +// arrow pointing child → parent, cardinality `n … 1`. The renderer is +// decoupled from the db structs — `build_diagram_table` adapts a +// `TableDescription`, and the layout/routing logic is unit-testable on +// plain `DiagramTable`s. Output is styled `OutputLine`s (ADR-0044 §5) +// composed from per-box styled segments. Side-by-side when the width +// allows, vertical-stack fallback otherwise (§3). + +/// One column as it appears inside a diagram box. +pub(crate) struct DiagramCol { + /// Column name. + pub name: String, + /// Type keyword; `None` in a compact box (`show table`) where only + /// the participating column name is shown. + pub type_text: Option, + /// Whether the column is part of its table's primary key. + pub pk: bool, + /// Whether the column is an endpoint of the relationship drawn. + pub endpoint: bool, +} + +/// A table as drawn in a relationship diagram. +pub(crate) struct DiagramTable { + /// Table name (the box's bold title row). + pub name: String, + /// Columns shown in the box (all for a full box, only the + /// participating ones for a compact box). + pub cols: Vec, +} + +/// The horizontal gutter between two side-by-side boxes. +const GUTTER: usize = 18; + +/// A styled line under construction: text plus its per-span runs +/// (ADR-0028 §5 / ADR-0044 §5). Segments compose by concatenation +/// with run offsets shifted, so two boxes + a gutter merge onto one +/// line without losing styling. +#[derive(Clone)] +struct Seg { + text: String, + runs: Vec, +} + +impl Seg { + const fn new() -> Self { + Self { + text: String::new(), + runs: Vec::new(), + } + } + + /// Append `s` as a run of `class`. Empty strings are ignored. + fn push(&mut self, s: &str, class: OutputStyleClass) { + if s.is_empty() { + return; + } + let start = self.text.len(); + self.text.push_str(s); + self.runs.push(OutputSpan { + byte_range: (start, self.text.len()), + class, + }); + } + + /// Append `n` spaces of `class` (padding). + fn pad(&mut self, n: usize, class: OutputStyleClass) { + if n > 0 { + self.push(&" ".repeat(n), class); + } + } + + /// Concatenate another segment, shifting its run offsets. + fn append(&mut self, other: &Self) { + let base = self.text.len(); + self.text.push_str(&other.text); + for r in &other.runs { + self.runs.push(OutputSpan { + byte_range: (r.byte_range.0 + base, r.byte_range.1 + base), + class: r.class, + }); + } + } + + fn into_line(self, mode: Mode) -> OutputLine { + OutputLine::styled(self.text, OutputKind::System, mode, self.runs) + } +} + +/// A laid-out box: equal-width styled lines plus the line index of +/// each endpoint column (where a connector attaches). +struct BoxLayout { + segs: Vec, + width: usize, + endpoint_rows: Vec, +} + +use crate::app::OutputStyleClass::{ + DiagramCardinality as Card, DiagramConnector as Conn, DiagramKey as Key, + DiagramTableName as TitleClass, Neutral, +}; + +/// A whole-line connector segment (borders / rules) of `text`. +fn conn_line(text: String) -> Seg { + let mut seg = Seg::new(); + seg.push(&text, Conn); + seg +} + +/// Lay out one table box: a full-width bold title row over a 1- or +/// 2-column body (label + optional type), styled per ADR-0044 §5. +fn render_box(t: &DiagramTable) -> BoxLayout { + let has_types = t.cols.iter().any(|c| c.type_text.is_some()); + + // Per-column label display width = name + ` (PK)` + ` ●` markers. + let label_w = t + .cols + .iter() + .map(|c| { + cell_width(&c.name) + + usize::from(c.pk) * 5 // " (PK)" + + usize::from(c.endpoint) * 2 // " ●" + }) + .max() + .unwrap_or(0); + let mut widths = vec![label_w]; + if has_types { + let type_w = t + .cols + .iter() + .map(|c| cell_width(c.type_text.as_deref().unwrap_or(""))) + .max() + .unwrap_or(0); + widths.push(type_w); + } + + // Inner width between the side borders == a body border's width + // minus the two corners: Σ(w+2) over columns + (ncols−1) dividers. + let ncols = widths.len(); + let body_inner: usize = widths.iter().map(|w| w + 2).sum::() + (ncols - 1); + // The title needs `name` + a space each side; if that exceeds the + // body width, widen the (first) label column so every row aligns. + let title_min = cell_width(&t.name) + 2; + let inner = if title_min > body_inner { + widths[0] += title_min - body_inner; + title_min + } else { + body_inner + }; + + let mut segs: Vec = Vec::with_capacity(t.cols.len() + 4); + segs.push(conn_line(h_border(inner, '┌', '┐'))); // top: title spans + segs.push(title_seg(&t.name, inner)); + segs.push(conn_line(border_row(&widths, BorderRow::TitleUnderline))); + for c in &t.cols { + // Use the (possibly title-widened) label column width so the + // body cells pad to the box width even when the name is wider. + segs.push(body_seg(c, widths[0], has_types.then(|| widths[1]))); + } + segs.push(conn_line(border_row(&widths, BorderRow::Bottom))); + + // Body row j sits at line index 3 (0=top, 1=title, 2=rule, 3+ body). + let endpoint_rows = t + .cols + .iter() + .enumerate() + .filter(|(_, c)| c.endpoint) + .map(|(j, _)| 3 + j) + .collect(); + + BoxLayout { + segs, + width: inner + 2, + endpoint_rows, + } +} + +/// A plain horizontal border of `inner` dashes between two corners. +fn h_border(inner: usize, left: char, right: char) -> String { + let mut s = String::new(); + s.push(left); + for _ in 0..inner { + s.push('─'); + } + s.push(right); + s +} + +/// The full-width title row `│ name │` (name in the +/// stand-out table-name style), padded to `inner`. +fn title_seg(name: &str, inner: usize) -> Seg { + let mut seg = Seg::new(); + seg.push("│", Conn); + seg.push(" ", Conn); + seg.push(name, TitleClass); + seg.pad(inner.saturating_sub(1 + cell_width(name)), Conn); + seg.push("│", Conn); + seg +} + +/// One body row: `│ name (PK) ● │ type │`, markers in the key style, +/// reproducing the byte layout of [`content_row`] so widths line up. +fn body_seg(c: &DiagramCol, label_w: usize, type_w: Option) -> Seg { + let mut seg = Seg::new(); + seg.push("│", Conn); + // Label cell. + seg.push(" ", Conn); + let mut used = cell_width(&c.name); + seg.push(&c.name, Neutral); + if c.pk { + seg.push(" (PK)", Key); + used += 5; + } + if c.endpoint { + seg.push(" ●", Key); + used += 2; + } + seg.pad(label_w.saturating_sub(used), Neutral); + seg.push(" ", Conn); + seg.push("│", Conn); + // Type cell (full box only). + if let Some(tw) = type_w { + let t = c.type_text.clone().unwrap_or_default(); + seg.push(" ", Conn); + seg.push(&t, Neutral); + seg.pad(tw.saturating_sub(cell_width(&t)), Neutral); + seg.push(" ", Conn); + seg.push("│", Conn); + } + 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; + 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[0] = 'n'; + } + if on_parent { + for c in &mut cells[vc + 1..w - 1] { + *c = '─'; + } + cells[w - 2] = '1'; + cells[w - 1] = '▶'; + } + + // 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(); + for ch in cells { + let class = if ch == 'n' || ch == '1' { Card } else { Conn }; + seg.push(&ch.to_string(), class); + } + seg +} + +/// The `on delete … · on update …` label below a diagram (muted). +fn action_seg(on_delete: &str, on_update: &str) -> Seg { + let mut seg = Seg::new(); + seg.push( + &format!(" on delete {on_delete} · on update {on_update}"), + crate::app::OutputStyleClass::Hint, + ); + seg +} + +/// A blank styled line of `w` spaces (fills a shorter box's side). +fn blank_seg(w: usize) -> Seg { + let mut seg = Seg::new(); + seg.pad(w, Neutral); + seg +} + +/// 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, + 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 + 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, &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, +/// and the optional pairing line. +fn compose_vertical( + cb: &BoxLayout, + pb: &BoxLayout, + pairing: Option<&str>, + on_delete: &str, + on_update: &str, +) -> Vec { + let indent = " "; + let mut out: Vec = cb.segs.clone(); + let mut a = Seg::new(); + a.push(indent, Conn); + a.push("│ n", Card); + 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, + ); + 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). A +/// compound FK (>1 paired column) also gets an explicit pairing line. +fn render_relationship_layout( + child: &DiagramTable, + parent: &DiagramTable, + on_delete: &str, + on_update: &str, + width: usize, +) -> Vec { + let cb = render_box(child); + let pb = render_box(parent); + 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, pairing.as_deref(), on_delete, on_update) + } else { + compose_vertical(&cb, &pb, pairing.as_deref(), on_delete, on_update) + } +} + +/// Build a full-box `DiagramTable` from a table description, marking +/// the columns that are this relationship's endpoints. +fn build_diagram_table(desc: &TableDescription, endpoint_cols: &[String]) -> DiagramTable { + DiagramTable { + name: desc.name.clone(), + cols: desc + .columns + .iter() + .map(|c| DiagramCol { + name: c.name.clone(), + type_text: Some(type_display(c)), + pk: c.primary_key, + endpoint: endpoint_cols.iter().any(|e| e == &c.name), + }) + .collect(), + } +} + +/// Render one relationship as a styled diagram (ADR-0044): the full +/// `show relationship ` view, both tables as full structure +/// boxes joined by a connector, laid out for `width`. +pub(crate) fn render_relationship_diagram( + data: &crate::db::RelationshipDiagramData, + width: u16, + mode: Mode, +) -> Vec { + let child = build_diagram_table(&data.child, &data.rel.child_columns); + let parent = build_diagram_table(&data.parent, &data.rel.parent_columns); + let on_delete = data.rel.on_delete.to_string(); + let on_update = data.rel.on_update.to_string(); + render_relationship_layout(&child, &parent, &on_delete, &on_update, width as usize) + .into_iter() + .map(|s| s.into_line(mode)) + .collect() +} + +/// A plain (unstyled) system output line — falls back to whole-line +/// `System` styling, exactly like `note_system`. +const fn plain_system(text: String, mode: Mode) -> OutputLine { + OutputLine { + text, + kind: OutputKind::System, + mode_at_submission: mode, + styled_runs: None, + status: None, + } +} + +/// A compact (name-only) box for one endpoint of a `show table` +/// relationship diagram (ADR-0044 §4): the table name + just the +/// participating column(s), all marked as endpoints. +fn compact_table(name: &str, cols: &[String]) -> DiagramTable { + DiagramTable { + name: name.to_string(), + cols: cols + .iter() + .map(|c| DiagramCol { + name: c.clone(), + type_text: None, + pk: false, + endpoint: true, + }) + .collect(), + } +} + +/// One relationship of the focal table as a compact connector diagram +/// (ADR-0044 §4). `outbound` = the focal table is the child (FK +/// holder, drawn left); otherwise it is the parent (drawn right). +fn render_compact_relationship( + focal: &str, + rel: &crate::db::RelationshipEnd, + outbound: bool, + width: usize, +) -> Vec { + let focal_box = compact_table(focal, &rel.local_columns); + let other_box = compact_table(&rel.other_table, &rel.other_columns); + let (child, parent) = if outbound { + (focal_box, other_box) + } else { + (other_box, focal_box) + }; + render_relationship_layout( + &child, + &parent, + &rel.on_delete.to_string(), + &rel.on_update.to_string(), + width, + ) +} + +/// `show table ` and relationship-DDL echoes (ADR-0044 §1, Diagram +/// mode): the focal structure box, then a **Relationships** section of +/// compact stacked diagrams, then indexes / table constraints. Box, +/// index and constraint sections are plain system lines; the diagrams +/// are styled. +pub(crate) fn render_structure_with_diagrams( + desc: &TableDescription, + width: u16, + mode: Mode, +) -> Vec { + let mut out: Vec = structure_box_lines(desc) + .into_iter() + .map(|s| plain_system(s, mode)) + .collect(); + + if !desc.outbound_relationships.is_empty() || !desc.inbound_relationships.is_empty() { + out.push(plain_system("Relationships".to_string(), mode)); + // Outbound (this table is the child) first, then inbound, each + // a compact connector diagram stacked vertically (ADR-0044 §4). + for rel in &desc.outbound_relationships { + for seg in render_compact_relationship(&desc.name, rel, true, width as usize) { + out.push(seg.into_line(mode)); + } + } + for rel in &desc.inbound_relationships { + for seg in render_compact_relationship(&desc.name, rel, false, width as usize) { + out.push(seg.into_line(mode)); + } + } + } + + for s in index_lines(desc) { + out.push(plain_system(s, mode)); + } + for s in constraint_lines(desc) { + out.push(plain_system(s, mode)); + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -547,6 +1118,255 @@ mod tests { use crate::dsl::ReferentialAction; use insta::assert_snapshot; + // ── Relationship visualization (ADR-0044) ────────────────────── + + fn dcol(name: &str, ty: &str, pk: bool, endpoint: bool) -> DiagramCol { + DiagramCol { + name: name.to_string(), + type_text: Some(ty.to_string()), + pk, + endpoint, + } + } + + /// `orders.customer_id → customers.id`, the canonical 1:n example. + fn orders_to_customers() -> (DiagramTable, DiagramTable) { + let child = DiagramTable { + name: "orders".to_string(), + cols: vec![ + dcol("id", "int", true, false), + dcol("customer_id", "int", false, true), + dcol("total", "real", false, false), + ], + }; + let parent = DiagramTable { + name: "customers".to_string(), + cols: vec![ + dcol("id", "int", true, true), + dcol("name", "text", false, false), + dcol("email", "text", false, false), + ], + }; + (child, parent) + } + + /// Join a laid-out diagram's segment text for assertions/snapshots. + fn layout_text(child: &DiagramTable, parent: &DiagramTable, width: usize) -> String { + render_relationship_layout(child, parent, "cascade", "no action", width) + .iter() + .map(|s| s.text.clone()) + .collect::>() + .join("\n") + } + + #[test] + fn relationship_diagram_single_column_side_by_side_snapshot() { + let (child, parent) = orders_to_customers(); + // Wide width forces the side-by-side layout. + let out = layout_text(&child, &parent, 200); + assert_snapshot!(out); + } + + #[test] + fn relationship_diagram_carries_names_cardinality_arrow_and_actions() { + let (child, parent) = orders_to_customers(); + let out = layout_text(&child, &parent, 200); + // Both tables named, FK marker present, connector + cardinality, + // child→parent arrow, and the referential actions line. + assert!(out.contains("orders"), "child name:\n{out}"); + assert!(out.contains("customers"), "parent name:\n{out}"); + assert!(out.contains("customer_id ●"), "FK marker:\n{out}"); + assert!(out.contains("id (PK) ●"), "parent endpoint marker:\n{out}"); + assert!(out.contains('▶'), "arrowhead:\n{out}"); + assert!(out.contains('n') && out.contains('1'), "cardinality:\n{out}"); + assert!( + out.contains("on delete cascade · on update no action"), + "actions:\n{out}" + ); + } + + #[test] + fn relationship_diagram_title_uses_table_name_style() { + use crate::app::OutputStyleClass; + let (child, parent) = orders_to_customers(); + let segs = render_relationship_layout(&child, &parent, "cascade", "no action", 200); + // A span styles the literal table name in the stand-out class + // (ADR-0044 §2.1 — the name must not read as a column). + let styled = segs.iter().any(|s| { + s.runs.iter().any(|r| { + r.class == OutputStyleClass::DiagramTableName + && &s.text[r.byte_range.0..r.byte_range.1] == "orders" + }) + }); + assert!(styled, "table name should carry DiagramTableName style"); + } + + #[test] + fn relationship_diagram_vertical_fallback_when_narrow() { + let (child, parent) = orders_to_customers(); + // A width too small for two boxes side by side stacks them. + let out = layout_text(&child, &parent, 20); + assert!(out.contains('▼'), "vertical connector:\n{out}"); + assert!(!out.contains('▶'), "no side-by-side arrow:\n{out}"); + let ci = out.find("orders").expect("child"); + let pi = out.find("customers").expect("parent"); + 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 { + name: "Customers".to_string(), + columns: vec![col("id", Type::Serial, true, false)], + outbound_relationships: Vec::new(), + inbound_relationships: vec![RelationshipEnd { + name: "cust_orders".to_string(), + other_table: "Orders".to_string(), + other_columns: vec!["cust_id".to_string()], + local_columns: vec!["id".to_string()], + on_delete: ReferentialAction::Cascade, + on_update: ReferentialAction::NoAction, + }], + indexes: Vec::new(), + unique_constraints: Vec::new(), + check_constraints: Vec::new(), + }; + let lines = render_structure_with_diagrams(&desc, 200, Mode::Simple); + let text = lines + .iter() + .map(|l| l.text.as_str()) + .collect::>() + .join("\n"); + // Diagram form: a Relationships heading + a connector, NOT the + // prose `Referenced by:` block. + assert!(text.contains("Relationships"), "heading:\n{text}"); + assert!(!text.contains("Referenced by:"), "no prose block:\n{text}"); + assert!(text.contains("Customers"), "focal box:\n{text}"); + assert!(text.contains("Orders"), "neighbour box:\n{text}"); + assert!(text.contains('▶'), "connector arrow:\n{text}"); + // Box lines plain; diagram lines styled. + assert!( + lines.iter().any(|l| l.styled_runs.is_some()), + "styled diagram lines", + ); + assert!( + lines.iter().any(|l| l.styled_runs.is_none()), + "plain box lines", + ); + assert_snapshot!(text); + } + fn col(name: &str, ty: Type, pk: bool, notnull: bool) -> ColumnDescription { ColumnDescription { name: name.to_string(), diff --git a/src/runtime.rs b/src/runtime.rs index 05543b7..9124b19 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1407,6 +1407,12 @@ fn spawn_dsl_dispatch( command: command.clone(), lines, }, + Ok(CommandOutcome::ShowRelationship(data)) => { + AppEvent::DslShowRelationshipSucceeded { + command: command.clone(), + data: data.map(|b| *b), + } + } Ok(CommandOutcome::QueryPlan(plan)) => AppEvent::DslExplainSucceeded { command: command.clone(), plan, @@ -2252,6 +2258,10 @@ enum CommandOutcome { /// the worker (table / relationship / index names). Pure /// display, no schema change. ShowList(Vec), + /// Structured data for one relationship's diagram (ADR-0044), + /// rendered App-side; `None` when the named relationship is absent. + /// Boxed — two full `TableDescription`s dwarf the other variants. + ShowRelationship(Option>), QueryPlan(QueryPlan), Insert(InsertResult), Update(UpdateResult), @@ -2774,6 +2784,16 @@ async fn execute_command_typed( .describe_table(name, src) .await .map(|d| CommandOutcome::Schema(Some(d))), + // ADR-0044: a named relationship renders as a diagram (App-side), + // so it returns structured data; every other `show ` form + // stays the worker-formatted prose list. + Command::ShowList { + kind: crate::dsl::command::ShowListKind::Relationships, + name: Some(name), + } => database + .show_relationship(name) + .await + .map(|opt| CommandOutcome::ShowRelationship(opt.map(Box::new))), Command::ShowList { kind, name } => database .show_list(kind, name) .await 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/src/snapshots/rdbms_playground__output_render__tests__relationship_diagram_single_column_side_by_side_snapshot.snap b/src/snapshots/rdbms_playground__output_render__tests__relationship_diagram_single_column_side_by_side_snapshot.snap new file mode 100644 index 0000000..bd81dc7 --- /dev/null +++ b/src/snapshots/rdbms_playground__output_render__tests__relationship_diagram_single_column_side_by_side_snapshot.snap @@ -0,0 +1,12 @@ +--- +source: src/output_render.rs +expression: out +--- +┌──────────────────────┐ ┌──────────────────┐ +│ orders │ │ customers │ +├───────────────┬──────┤ ├───────────┬──────┤ +│ id (PK) │ int │ ┌──────1▶│ id (PK) ● │ int │ +│ customer_id ● │ int │n────────┘ │ name │ text │ +│ total │ real │ │ email │ text │ +└───────────────┴──────┘ └───────────┴──────┘ + on delete cascade · on update no action diff --git a/src/snapshots/rdbms_playground__output_render__tests__render_structure_with_diagrams_replaces_prose_with_compact_diagrams.snap b/src/snapshots/rdbms_playground__output_render__tests__render_structure_with_diagrams_replaces_prose_with_compact_diagrams.snap new file mode 100644 index 0000000..2c8cf8f --- /dev/null +++ b/src/snapshots/rdbms_playground__output_render__tests__render_structure_with_diagrams_replaces_prose_with_compact_diagrams.snap @@ -0,0 +1,17 @@ +--- +source: src/output_render.rs +expression: text +--- +Customers +┌──────┬────────┬─────────────┐ +│ Name │ Type │ Constraints │ +├──────┼────────┼─────────────┤ +│ id │ serial │ PK │ +└──────┴────────┴─────────────┘ +Relationships +┌───────────┐ ┌───────────┐ +│ Orders │ │ Customers │ +├───────────┤ ├───────────┤ +│ cust_id ● │n───────────────1▶│ id ● │ +└───────────┘ └───────────┘ + on delete cascade · on update no action diff --git a/src/ui.rs b/src/ui.rs index 30f29a2..cd60b95 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -667,6 +667,9 @@ fn render_output_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area // mutable `note_output_viewport` call below). let total_wrapped = approximate_wrapped_rows_from_output(&app.output, inner.width); app.note_output_viewport(visible, total_wrapped); + // ADR-0044 §3: record the panel width so a later `show relationship` + // diagram (rendered App-side) can choose side-by-side vs vertical. + app.last_output_width = inner.width; let lines: Vec> = app .output @@ -756,6 +759,19 @@ const fn output_span_style(class: OutputStyleClass, theme: &Theme) -> Style { // existing `client_side.*` notes). `theme.muted` is the // established dim foreground. OutputStyleClass::Hint => Style::new().fg(theme.muted), + // ADR-0044 relationship diagrams. Reuse existing theme colours + // (no new Theme fields): the table name stands out via weight, + // keys + cardinality take accent colours, connectors are muted. + OutputStyleClass::DiagramTableName => { + Style::new().fg(theme.fg).add_modifier(Modifier::BOLD) + } + OutputStyleClass::DiagramKey => Style::new() + .fg(theme.plan_efficient) + .add_modifier(Modifier::BOLD), + OutputStyleClass::DiagramCardinality => Style::new() + .fg(theme.tok_number) + .add_modifier(Modifier::BOLD), + OutputStyleClass::DiagramConnector => Style::new().fg(theme.muted), } } 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()], + ); + }); +} diff --git a/tests/it/show_list.rs b/tests/it/show_list.rs index ea6f31b..f3750e7 100644 --- a/tests/it/show_list.rs +++ b/tests/it/show_list.rs @@ -357,3 +357,135 @@ fn app_renders_show_list_lines_as_system_output() { "item line rendered", ); } + +// ================================================================= +// ADR-0044 — `show relationship ` renders a diagram +// ================================================================= + +#[test] +fn show_relationship_worker_returns_structured_diagram_data() { + let (_p, db, _dir) = open_project_db(); + let rt = rt(); + rt.block_on(seed_schema(&db)); + let data = rt + .block_on(db.show_relationship("orders_customer".to_string())) + .expect("show_relationship ok") + .expect("relationship found"); + assert_eq!(data.rel.name, "orders_customer"); + // child = FK holder, parent = referenced (ADR-0044 left/right). + assert_eq!(data.child.name, "Orders"); + assert_eq!(data.parent.name, "Customers"); + assert_eq!(data.rel.child_columns, vec!["customer_id".to_string()]); + assert_eq!(data.rel.parent_columns, vec!["id".to_string()]); +} + +#[test] +fn show_relationship_worker_returns_none_for_unknown_name() { + let (_p, db, _dir) = open_project_db(); + let rt = rt(); + rt.block_on(seed_schema(&db)); + assert!( + rt.block_on(db.show_relationship("nope".to_string())) + .expect("ok") + .is_none(), + "unknown relationship → None", + ); +} + +#[test] +fn app_renders_show_relationship_as_a_styled_diagram() { + let (_p, db, _dir) = open_project_db(); + let rt = rt(); + rt.block_on(seed_schema(&db)); + let data = rt + .block_on(db.show_relationship("orders_customer".to_string())) + .expect("ok") + .expect("found"); + + let mut app = App::new(); + app.output.push_back(rdbms_playground::app::OutputLine::echo( + "show relationship orders_customer", + Mode::Simple, + )); + app.update(AppEvent::DslShowRelationshipSucceeded { + command: Command::ShowList { + kind: ShowListKind::Relationships, + name: Some("orders_customer".to_string()), + }, + data: Some(data), + }); + let text: String = app + .output + .iter() + .map(|l| l.text.as_str()) + .collect::>() + .join("\n"); + // Both tables, box-drawing, the connector arrow, the actions line. + assert!(text.contains("Orders"), "child box: {text}"); + assert!(text.contains("Customers"), "parent box: {text}"); + assert!(text.contains('┌') && text.contains('│'), "box drawing: {text}"); + assert!(text.contains('▶'), "connector arrow: {text}"); + assert!(text.contains("on delete cascade"), "actions: {text}"); + // The diagram lines are styled (per-span runs), not plain system. + assert!( + app.output.iter().any(|l| l.styled_runs.is_some()), + "diagram lines carry styled runs", + ); +} + +#[test] +fn app_show_relationship_not_found_shows_friendly_line() { + let mut app = App::new(); + app.output.push_back(rdbms_playground::app::OutputLine::echo( + "show relationship nope", + Mode::Simple, + )); + app.update(AppEvent::DslShowRelationshipSucceeded { + command: Command::ShowList { + kind: ShowListKind::Relationships, + name: Some("nope".to_string()), + }, + data: None, + }); + assert!( + app.output + .iter() + .any(|l| l.text == "No relationship named `nope`."), + "friendly not-found line", + ); +} + +#[test] +fn app_show_table_renders_relationships_as_compact_diagrams() { + let (_p, db, _dir) = open_project_db(); + let rt = rt(); + rt.block_on(seed_schema(&db)); + // Orders holds the FK to Customers — an outbound relationship. + let desc = rt + .block_on(db.describe_table("Orders".to_string(), None)) + .expect("describe Orders"); + + let mut app = App::new(); + app.output.push_back(rdbms_playground::app::OutputLine::echo( + "show table Orders", + Mode::Simple, + )); + app.update(AppEvent::DslSucceeded { + command: Command::ShowTable { + name: "Orders".to_string(), + }, + description: Some(desc), + echo: None, + }); + let text: String = app + .output + .iter() + .map(|l| l.text.as_str()) + .collect::>() + .join("\n"); + // The focal structure box, then a diagram (not the prose block). + assert!(text.contains("Relationships"), "diagram heading: {text}"); + assert!(!text.contains("References:"), "prose suppressed: {text}"); + assert!(text.contains("Customers"), "neighbour box: {text}"); + assert!(text.contains('▶'), "connector arrow: {text}"); +} diff --git a/tests/it/walking_skeleton.rs b/tests/it/walking_skeleton.rs index 2be6ea4..24646e7 100644 --- a/tests/it/walking_skeleton.rs +++ b/tests/it/walking_skeleton.rs @@ -473,9 +473,15 @@ fn add_relationship_flow_shows_parent_side_with_inbound_section() { echo: None, }); - let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24); - assert!(rendered.contains("Referenced by:"), "{rendered}"); - assert!(rendered.contains("Orders.CustId"), "{rendered}"); + // Tall viewport so the [ok] echo line stays visible above the + // (taller-than-prose) diagram for the endpoint-subject assertion. + let rendered = rendered_text(&mut app, &Theme::dark(), 80, 40); + // ADR-0044: `add relationship` is relationship-relevant, so its echo + // renders the relationship as a compact diagram, not the prose block. + assert!(rendered.contains("Relationships"), "heading: {rendered}"); + assert!(rendered.contains("Orders"), "neighbour box: {rendered}"); + assert!(rendered.contains("CustId"), "FK column: {rendered}"); + assert!(rendered.contains('▶'), "connector: {rendered}"); assert!(rendered.contains("on delete cascade"), "{rendered}"); // The [ok] subject lists the endpoints. Long lines wrap in // the panel, so we check the first half of the phrase only.