feat: compound-FK bus routing + complete V1 relationship visualization (ADR-0044)
Completes requirement V1. A compound (multi-column) FK now routes a bus connector — each paired endpoint's stub merges into a shared vertical channel that splits to the other side — plus an explicit "(a, b) ▶ P.(x, y)" pairing line; the bus generalises the single-column jog (reproducing it exactly, so prior snapshots are unchanged). Self-referential FKs render as two same-named boxes. - output_render.rs: gutter_seg routes all endpoint pairs via a junction() bus; pairing line for compound FKs; compound, self-ref, and compound-from-data (build_diagram_table glue) tests + snapshots - compound_fk.rs: worker test that show_relationship carries both paired column lists into the diagram payload - db.rs: document do_show_one's now-app-superseded relationship prose branch (retained as a worker-API/text fallback; could back a future non-visual display option, cf. ADR-0044 OOS-7) Second /runda pass over the implementation: confirmed ADR-compliance, UTF-8/byte-range safety, and edge-case routing. The ADR §3 last-resort helper line was considered and rejected (vertical fallback + ratatui truncation cover all realistic cases). ADR-0044 marked implemented; requirements.md V1 -> [x]. Full suite 2207 pass / 0 fail / 1 ignored; clippy nursery clean.
This commit is contained in:
@@ -2,7 +2,13 @@
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-06-09)
|
||||
Accepted (2026-06-09); **implemented 2026-06-10** (commits: `cad90ec`
|
||||
`show relationship` full diagram, `a0ee323` `show table` compact
|
||||
diagrams, + compound-FK bus routing and self-referential diagrams). A
|
||||
second `/runda` DA pass over the implementation confirmed
|
||||
ADR-compliance, UTF-8/byte-range safety, and edge-case routing; the §3
|
||||
last-resort helper line was **considered and rejected** (see §3).
|
||||
Closes `requirements.md` **V1**.
|
||||
|
||||
Resolves **ADR-0016 OOS-1** ("Relationship visualization — two
|
||||
structures side-by-side with an arrow; its own ADR; will compose
|
||||
@@ -251,14 +257,20 @@ render. The App-side render path then chooses layout per diagram:
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
- **Last-resort helper.** Only when even a single vertical diagram
|
||||
cannot fit (e.g. an extremely narrow pane, or a box wider than the
|
||||
pane), `show table` emits a one-line pointer per relationship —
|
||||
`<child> → <parent> · run `show relationship <name>` for the
|
||||
diagram` — so nothing is silently dropped. `show relationship`
|
||||
itself always renders (it is the detail view); if its single box is
|
||||
wider than the pane, ratatui's existing right-edge handling applies
|
||||
(ADR-0016 §4), no new truncation.
|
||||
- **Last-resort helper — considered and rejected (2026-06-10,
|
||||
implementation).** The draft proposed that, when even a single
|
||||
*vertical* diagram cannot fit (a box wider than the pane), `show
|
||||
table` emit a one-line `run `show relationship <name>`` 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
|
||||
|
||||
+1
-1
@@ -49,4 +49,4 @@ This directory contains the project's ADRs, recorded per
|
||||
- [ADR-0041 — Copy the output panel to the system clipboard](0041-copy-output-to-clipboard.md) — **Accepted 2026-06-02 (issue #11)**, amends ADR-0003's app-command registry (adds **`copy`** / `copy all` / `copy last`). The friction it removes: filing a bug report meant terminal-selecting the output panel and fighting wrapping/borders. New **app-level command** (sigil-free, both modes): `copy` / `copy all` copy the whole panel; `copy last` copies from the most recent echo line to the end. **Mechanism — OSC 52 *and* native (`arboard`), always both**, because OSC 52 acceptance is undetectable (no terminal ack), so a true "fall back when unsupported" can't be built: emit the OSC 52 escape (no new dep — `base64`+`crossterm`; works over SSH; tmux-passthrough-wrapped via `$TMUX`), then a best-effort native write whose failure is ignored (headless host — OSC 52 carried it); the two carry identical content. **Format — plain text verbatim as rendered** (tags, `✓`/`✗`, box-drawing) joined by `\n`, without viewport padding/wrapping; a drift-lock test pins `OutputLine::plain_text` to `render_output_line`. `arboard` added **`--no-default-features`** (drops the `image` crate; X11-only on Linux — `wayland-data-control` deliberately omitted as it ~doubles the dep tree and OSC 52 covers native-Wayland). Security: write-only, scans clean for arboard's tree (cargo audit / osv-scanner / grype), 1Password-maintained, minimal surface. OOS: Markdown export, selection/range, a keybinding, OSC 52 read, `screen` passthrough
|
||||
- [ADR-0042 — H1a parse-error pedagogy in the grammar-tree era](0042-h1a-parse-error-pedagogy-grammar-tree.md) — **Accepted 2026-06-03.** Continues **H1a** from ADR-0021 against the ADR-0024 grammar tree (ADR-0021's chumsky mechanism is dead). Records the **baseline already shipped** — per-command `usage:` block (38 `parse.usage.*` templates), available-commands fallback, structural "after `…`, expected …" wording, source-derived ident slot labels ("table name"/"column name"), curated `parse.custom.*` near-miss messages, and the ADR-0027/0033/0036 schema-aware `[ERR]` diagnostics — so H1a is *substantially* delivered at the intent level. Defines the remaining work as **(1)** a verified per-command **near-miss matrix** (`tests/typing_surface/` + `tests/it/parse_error_pedagogy.rs`) as the definition of done, test-first; **(2)** **friendlier literal expectation labels** — optional prose glosses on `Word`/`Punct`/`Flag` positions that *add* role context while always keeping the exact literal visible (e.g. "a filter clause: `where …` or `--all-rows`"); **(3)** **advanced-mode SQL** near-miss parity (RETURNING scope, CTE-arity positioning, `CROSS JOIN … ON`, INSERT…SELECT count) — **in scope**, kept distinct from ADR-0019 §OOS-2 which covers advanced-SQL *engine*-error sanitisation, a different layer. Catalog/anchor-phrase discipline (ADR-0019) preserved; no public API change. OOS: I3/I4, spell-correction, multi-error reporting, verbosity-gating the usage block
|
||||
- [ADR-0043 — Compound-primary-key foreign-key references (T3)](0043-compound-pk-foreign-key-references.md) — **Accepted + implemented 2026-06-09** (all four forks confirmed at the recommended option: full-PK matching, house-style uniform lists, parenthesized DSL syntax, bare-SQL-FK auto-expansion). Closes `requirements.md` **T3** `[x]` — the relationship model went list-based across six layers (single-column preserved, no migration), DSL `from P.(a,b) to C.(x,y)` + SQL `FOREIGN KEY (a,b) REFERENCES P(x,y)` parse/execute/enforce, 12 tests in `tests/it/compound_fk.rs`. Closes the open leg of `requirements.md` **T3**: a foreign key that *references* a parent's compound primary key. A 2026-06-09 audit found single-column FK woven through ~15–20 sites (metadata table, `RelationshipSchema`, `project.yaml` `RawEndpoint`, both grammar surfaces, executor FK-DDL emission, per-column type-compat, display) — earns an ADR, not an inline build. **Decision:** reference the parent's **full** compound PK, matched **positionally** to an equal-length child column list, per-pair `fk_target_type` compat (ADR-0011, element-wise); DSL `from <P>.(a, b) to <C>.(x, y)` (single form unchanged), SQL `FOREIGN KEY (x, y) REFERENCES P(a, b)` (extend the existing one-cap lists; bare table-level FK auto-expands to the parent PK when arities match). **Storage — no migration (back-compat not required, user-confirmed 2026-06-09; no installed base):** the relationship endpoint joins the list convention `project.yaml` *already* uses — `columns: [a, b]` like `primary_key: [id]` and index `columns: [...]` (the endpoint was the lone scalar `column:` holdout); the metadata `TEXT` columns are unchanged and store the list **comma-joined** (`a,b`; the bare name for single — safe because identifiers are `[A-Za-z0-9_]+`). No F3 migrator, no version bump; accepted trade-off is that a pre-change `project.yaml` with relationships won't load (clean cutover). In-memory model goes list-based (`Vec<String>`) through all six layers; the enforced FK is the rebuilt child-table DDL (`FOREIGN KEY (a,b) REFERENCES P(x,y)`), one relationship = one undo step (ADR-0013). Genuine forks escalated: matching policy (full-PK vs subset), storage (house-style uniform lists vs normalized table), DSL syntax (parenthesized vs repeated-dotted), bare-SQL-FK auto-expansion. OOS: subset/non-PK (UNIQUE-targeted) FK references; any single-column behaviour change
|
||||
- [ADR-0044 — Relationship visualization (two-table connector diagrams)](0044-relationship-visualization.md) — **Accepted 2026-06-09** (revised post-`/runda` DA pass). Resolves **ADR-0016 OOS-1** and closes the open half of `requirements.md` **V1** ("a selected relationship as two tables joined by a line"). Renders a relationship as **Style A** (two structure boxes + connector). **Reach = "relationship-relevant"** (user-chosen over global / show-only): diagrams on the surfaces where the relationship is the *subject* — `show relationship <name>` (one full diagram), `show table <T>` (T's structure box then a **Relationships** section of **stacked compact** per-relationship diagrams — chosen over a focal-centred subgraph: no crossing lines, scales via scroll, two-boxes-wide fits any terminal), and relationship DDL echoes (`add`/`drop`/`modify relationship`); incidental DDL echoes (`add column`, `drop index`, `change column`, plain `create table`) keep the terse prose, via a `Diagram`|`Prose` render mode on `render_structure`. Reading convention **child(FK)-left / parent-right, arrow →, `n`…`1` cardinality**, applied uniformly; every box gets a **bold title row + rule** so the name can't read as a column. **Compound FKs** (ADR-0043) route one connector per positional pair + an explicit pairing line. **Width-aware** (first in the codebase) but **App-side**: `render_structure`/diagram rendering runs in `app.rs` (the worker only returns `TableDescription`s), a new `App::last_output_width` (set from `ui.rs`) drives side-by-side vs a **vertical-stack** fallback + last-resort "run `show relationship`" pointer; rendered once at command time, **no live reflow** (V4). `show relationship`'s worker path (`do_show_one`, prose-only) is restructured to return both endpoint `TableDescription`s. Styling reuses **ADR-0028** App-side styled runs (new classes: table-name/key/connector/cardinality/action) — no worker→UI contract change. **Partially supersedes ADR-0016 §5** (prose block replaced on relationship-subject surfaces, retained on incidental ones); extends §4 (layout width-awareness, still no cell truncation) and §6 (per-span theming). Tests: insta snapshots (single, compound, vertical fallback, helper line, self-referential, multi-rel `show table`) + width-threshold/routing unit tests + Tier-3 wiring; enumerated prose-fallout updates (`output_render.rs:121/135/793`, the relationships snapshot, `walking_skeleton.rs:477/530`). A `/runda` DA pass corrected three inverted-architecture claims (App-side rendering, untracked width, prose-in-worker show-relationship) before acceptance. OOS: user-configurable display setting (OOS-7), live reflow (V4), whole-DB ER export (V3), m:n (C4), ASCII fallback (ADR-0016 OOS-5)
|
||||
- [ADR-0044 — Relationship visualization (two-table connector diagrams)](0044-relationship-visualization.md) — **Accepted 2026-06-09; implemented 2026-06-10** (closes `requirements.md` V1; second `/runda` pass over the implementation; §3 last-resort helper line considered and rejected). Resolves **ADR-0016 OOS-1** and closes the open half of `requirements.md` **V1** ("a selected relationship as two tables joined by a line"). Renders a relationship as **Style A** (two structure boxes + connector). **Reach = "relationship-relevant"** (user-chosen over global / show-only): diagrams on the surfaces where the relationship is the *subject* — `show relationship <name>` (one full diagram), `show table <T>` (T's structure box then a **Relationships** section of **stacked compact** per-relationship diagrams — chosen over a focal-centred subgraph: no crossing lines, scales via scroll, two-boxes-wide fits any terminal), and relationship DDL echoes (`add`/`drop`/`modify relationship`); incidental DDL echoes (`add column`, `drop index`, `change column`, plain `create table`) keep the terse prose, via a `Diagram`|`Prose` render mode on `render_structure`. Reading convention **child(FK)-left / parent-right, arrow →, `n`…`1` cardinality**, applied uniformly; every box gets a **bold title row + rule** so the name can't read as a column. **Compound FKs** (ADR-0043) route one connector per positional pair + an explicit pairing line. **Width-aware** (first in the codebase) but **App-side**: `render_structure`/diagram rendering runs in `app.rs` (the worker only returns `TableDescription`s), a new `App::last_output_width` (set from `ui.rs`) drives side-by-side vs a **vertical-stack** fallback + last-resort "run `show relationship`" pointer; rendered once at command time, **no live reflow** (V4). `show relationship`'s worker path (`do_show_one`, prose-only) is restructured to return both endpoint `TableDescription`s. Styling reuses **ADR-0028** App-side styled runs (new classes: table-name/key/connector/cardinality/action) — no worker→UI contract change. **Partially supersedes ADR-0016 §5** (prose block replaced on relationship-subject surfaces, retained on incidental ones); extends §4 (layout width-awareness, still no cell truncation) and §6 (per-span theming). Tests: insta snapshots (single, compound, vertical fallback, helper line, self-referential, multi-rel `show table`) + width-threshold/routing unit tests + Tier-3 wiring; enumerated prose-fallout updates (`output_render.rs:121/135/793`, the relationships snapshot, `walking_skeleton.rs:477/530`). A `/runda` DA pass corrected three inverted-architecture claims (App-side rendering, untracked width, prose-in-worker show-relationship) before acceptance. OOS: user-configurable display setting (OOS-7), live reflow (V4), whole-DB ER export (V3), m:n (C4), ASCII fallback (ADR-0016 OOS-5)
|
||||
|
||||
+15
-11
@@ -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 <name>` (full
|
||||
structure boxes), `show table <T>` 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 —
|
||||
|
||||
@@ -6010,6 +6010,15 @@ fn do_show_list(
|
||||
/// labelled block, or a friendly "no such item" line. `Tables` is
|
||||
/// never routed here (the table singular is `ShowTable`); the
|
||||
/// defensive arm keeps the match total without a panic.
|
||||
///
|
||||
/// **The `Relationships` arm is superseded for the app by
|
||||
/// `do_show_relationship` (ADR-0044): the runtime reroutes a named
|
||||
/// `show relationship` to the structured diagram path, so this prose
|
||||
/// form is no longer shown to users.** It is retained — reachable via
|
||||
/// the `Database::show_list` worker API and covered by a worker test —
|
||||
/// as a text fallback that could back a future non-visual display
|
||||
/// option (cf. ADR-0044 OOS-7's relationship-display setting). The
|
||||
/// `Indexes` arm remains live (`show index <name>` still routes here).
|
||||
fn do_show_one(
|
||||
conn: &Connection,
|
||||
kind: crate::dsl::command::ShowListKind,
|
||||
|
||||
+212
-35
@@ -786,38 +786,64 @@ fn body_seg(c: &DiagramCol, label_w: usize, type_w: Option<usize>) -> Seg {
|
||||
seg
|
||||
}
|
||||
|
||||
/// One row of the gutter as a styled segment: routes the connector
|
||||
/// from the child endpoint row (`crow`) to the parent endpoint row
|
||||
/// (`prow`), `n` at the child end and `1` at the parent end, a `▶`
|
||||
/// arrowhead into the parent. Handles the straight (same height) and
|
||||
/// jogged (differing height) cases.
|
||||
fn gutter_seg(i: usize, crow: usize, prow: usize, w: usize) -> Seg {
|
||||
/// The box-drawing glyph for a bus junction given which directions it
|
||||
/// connects (up / down the bus, a child stub from the left, a parent
|
||||
/// stub to the right).
|
||||
const fn junction(up: bool, down: bool, left: bool, right: bool) -> char {
|
||||
match (up, down, left, right) {
|
||||
(true, true, true, true) => '┼',
|
||||
(true, true, true, false) => '┤',
|
||||
(true, true, false, true) => '├',
|
||||
(true, true, false, false) => '│',
|
||||
(true, false, true, true) => '┴',
|
||||
(true, false, true, false) => '┘',
|
||||
(true, false, false, true) => '└',
|
||||
(false, true, true, true) => '┬',
|
||||
(false, true, true, false) => '┐',
|
||||
(false, true, false, true) => '┌',
|
||||
(false, false, true, _) | (false, false, false, true) => '─',
|
||||
_ => '│',
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of the gutter as a styled segment, routing **all** endpoint
|
||||
/// pairs (ADR-0044 §2.3 / §2.4): each child endpoint row gets an `n`
|
||||
/// stub from the left, each parent endpoint row a `1` stub + `▶` to the
|
||||
/// right, both merging into a shared vertical bus at the centre. For a
|
||||
/// single-column FK this reduces to the simple jogged connector.
|
||||
fn gutter_seg(i: usize, child_rows: &[usize], parent_rows: &[usize], w: usize) -> Seg {
|
||||
let mut cells = vec![' '; w];
|
||||
let vc = w / 2;
|
||||
if crow == prow {
|
||||
if i == crow {
|
||||
for c in &mut cells[1..w - 1] {
|
||||
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';
|
||||
cells[w - 2] = '1';
|
||||
cells[w - 1] = '▶';
|
||||
}
|
||||
} else if i == crow {
|
||||
for c in &mut cells[..vc] {
|
||||
*c = '─';
|
||||
}
|
||||
cells[vc] = if crow < prow { '┐' } else { '┘' };
|
||||
cells[0] = 'n';
|
||||
} else if i == prow {
|
||||
cells[vc] = if prow > crow { '└' } else { '┌' };
|
||||
if on_parent {
|
||||
for c in &mut cells[vc + 1..w - 1] {
|
||||
*c = '─';
|
||||
}
|
||||
cells[w - 2] = '1';
|
||||
cells[w - 1] = '▶';
|
||||
} else if i > crow.min(prow) && i < crow.max(prow) {
|
||||
cells[vc] = '│';
|
||||
}
|
||||
|
||||
// The vertical bus spans the full range of endpoint rows.
|
||||
let bounds = child_rows
|
||||
.iter()
|
||||
.chain(parent_rows)
|
||||
.copied()
|
||||
.fold(None, |acc: Option<(usize, usize)>, r| {
|
||||
Some(acc.map_or((r, r), |(lo, hi)| (lo.min(r), hi.max(r))))
|
||||
});
|
||||
if let Some((top, bot)) = bounds
|
||||
&& i >= top
|
||||
&& i <= bot
|
||||
{
|
||||
cells[vc] = junction(i > top, i < bot, on_child, on_parent);
|
||||
}
|
||||
|
||||
let mut seg = Seg::new();
|
||||
@@ -845,36 +871,48 @@ fn blank_seg(w: usize) -> Seg {
|
||||
seg
|
||||
}
|
||||
|
||||
/// Two boxes side by side, joined by the gutter connector (ADR-0044
|
||||
/// §2.3), with the actions line beneath.
|
||||
/// The explicit pairing line for a compound FK (ADR-0044 §2.4).
|
||||
fn pairing_seg(text: &str) -> Seg {
|
||||
let mut seg = Seg::new();
|
||||
seg.push(&format!(" {text}"), Neutral);
|
||||
seg
|
||||
}
|
||||
|
||||
/// Two boxes side by side, joined by the bus connector (ADR-0044
|
||||
/// §2.3/§2.4), with an optional compound-FK pairing line and the
|
||||
/// actions line beneath.
|
||||
fn compose_side_by_side(
|
||||
cb: &BoxLayout,
|
||||
pb: &BoxLayout,
|
||||
crow: usize,
|
||||
prow: usize,
|
||||
pairing: Option<&str>,
|
||||
on_delete: &str,
|
||||
on_update: &str,
|
||||
) -> Vec<Seg> {
|
||||
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<Seg> = Vec::with_capacity(height + 1);
|
||||
let mut out: Vec<Seg> = Vec::with_capacity(height + 2);
|
||||
for i in 0..height {
|
||||
let mut seg = Seg::new();
|
||||
seg.append(cb.segs.get(i).unwrap_or(&blank_l));
|
||||
seg.append(&gutter_seg(i, crow, prow, GUTTER));
|
||||
seg.append(&gutter_seg(i, &cb.endpoint_rows, &pb.endpoint_rows, GUTTER));
|
||||
seg.append(pb.segs.get(i).unwrap_or(&blank_r));
|
||||
out.push(seg);
|
||||
}
|
||||
if let Some(p) = pairing {
|
||||
out.push(pairing_seg(p));
|
||||
}
|
||||
out.push(action_seg(on_delete, on_update));
|
||||
out
|
||||
}
|
||||
|
||||
/// Vertical-stack fallback for narrow terminals (ADR-0044 §3): child
|
||||
/// box, a downward connector carrying the actions, then the parent box.
|
||||
/// box, a downward connector carrying the actions, then the parent box,
|
||||
/// and the optional pairing line.
|
||||
fn compose_vertical(
|
||||
cb: &BoxLayout,
|
||||
pb: &BoxLayout,
|
||||
pairing: Option<&str>,
|
||||
on_delete: &str,
|
||||
on_update: &str,
|
||||
) -> Vec<Seg> {
|
||||
@@ -883,19 +921,29 @@ fn compose_vertical(
|
||||
let mut a = Seg::new();
|
||||
a.push(indent, Conn);
|
||||
a.push("│ n", Card);
|
||||
a.push(&format!(" on delete {on_delete}"), crate::app::OutputStyleClass::Hint);
|
||||
a.push(
|
||||
&format!(" on delete {on_delete}"),
|
||||
crate::app::OutputStyleClass::Hint,
|
||||
);
|
||||
out.push(a);
|
||||
let mut b = Seg::new();
|
||||
b.push(indent, Conn);
|
||||
b.push("▼ 1", Card);
|
||||
b.push(&format!(" on update {on_update}"), crate::app::OutputStyleClass::Hint);
|
||||
b.push(
|
||||
&format!(" on update {on_update}"),
|
||||
crate::app::OutputStyleClass::Hint,
|
||||
);
|
||||
out.push(b);
|
||||
out.extend(pb.segs.clone());
|
||||
if let Some(p) = pairing {
|
||||
out.push(pairing_seg(p));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Lay out a relationship between two `DiagramTable`s at `width`,
|
||||
/// choosing side-by-side or the vertical fallback (ADR-0044 §3).
|
||||
/// choosing side-by-side or the vertical fallback (ADR-0044 §3). A
|
||||
/// compound FK (>1 paired column) also gets an explicit pairing line.
|
||||
fn render_relationship_layout(
|
||||
child: &DiagramTable,
|
||||
parent: &DiagramTable,
|
||||
@@ -905,12 +953,30 @@ fn render_relationship_layout(
|
||||
) -> Vec<Seg> {
|
||||
let cb = render_box(child);
|
||||
let pb = render_box(parent);
|
||||
let crow = cb.endpoint_rows.first().copied().unwrap_or(0);
|
||||
let prow = pb.endpoint_rows.first().copied().unwrap_or(0);
|
||||
let child_cols: Vec<&str> = child
|
||||
.cols
|
||||
.iter()
|
||||
.filter(|c| c.endpoint)
|
||||
.map(|c| c.name.as_str())
|
||||
.collect();
|
||||
let parent_cols: Vec<&str> = parent
|
||||
.cols
|
||||
.iter()
|
||||
.filter(|c| c.endpoint)
|
||||
.map(|c| c.name.as_str())
|
||||
.collect();
|
||||
let pairing = (child_cols.len() > 1).then(|| {
|
||||
format!(
|
||||
"({}) ▶ {}.({})",
|
||||
child_cols.join(", "),
|
||||
parent.name,
|
||||
parent_cols.join(", "),
|
||||
)
|
||||
});
|
||||
if cb.width + GUTTER + pb.width <= width.max(1) {
|
||||
compose_side_by_side(&cb, &pb, crow, prow, on_delete, on_update)
|
||||
compose_side_by_side(&cb, &pb, pairing.as_deref(), on_delete, on_update)
|
||||
} else {
|
||||
compose_vertical(&cb, &pb, on_delete, on_update)
|
||||
compose_vertical(&cb, &pb, pairing.as_deref(), on_delete, on_update)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1147,6 +1213,117 @@ mod tests {
|
||||
assert!(pi > ci, "parent stacked below child:\n{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relationship_diagram_compound_fk_routes_a_bus_and_pairing_line() {
|
||||
// A 2-column FK: (cust_region, cust_id) → customers.(region, id).
|
||||
let child = DiagramTable {
|
||||
name: "orders".to_string(),
|
||||
cols: vec![
|
||||
dcol("cust_region", "text", false, true),
|
||||
dcol("cust_id", "int", false, true),
|
||||
dcol("total", "real", false, false),
|
||||
],
|
||||
};
|
||||
let parent = DiagramTable {
|
||||
name: "customers".to_string(),
|
||||
cols: vec![
|
||||
dcol("region", "text", true, true),
|
||||
dcol("id", "int", true, true),
|
||||
dcol("name", "text", false, false),
|
||||
],
|
||||
};
|
||||
let out = layout_text(&child, &parent, 200);
|
||||
// Both endpoint pairs marked, the bus joins them, and an explicit
|
||||
// pairing line removes any ambiguity (ADR-0044 §2.4).
|
||||
assert!(out.contains("cust_region ●"), "child ep 1:\n{out}");
|
||||
assert!(out.contains("cust_id ●"), "child ep 2:\n{out}");
|
||||
assert!(
|
||||
out.contains("(cust_region, cust_id) ▶ customers.(region, id)"),
|
||||
"pairing line:\n{out}",
|
||||
);
|
||||
assert_snapshot!(out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relationship_diagram_self_referential_shows_two_same_named_boxes() {
|
||||
// Employee.manager_id → Employee.id (a self-referential FK):
|
||||
// rendered as two boxes bearing the same name (ADR-0044 §6).
|
||||
let child = DiagramTable {
|
||||
name: "Employee".to_string(),
|
||||
cols: vec![
|
||||
dcol("id", "serial", true, false),
|
||||
dcol("manager_id", "int", false, true),
|
||||
],
|
||||
};
|
||||
let parent = DiagramTable {
|
||||
name: "Employee".to_string(),
|
||||
cols: vec![
|
||||
dcol("id", "serial", true, true),
|
||||
dcol("manager_id", "int", false, false),
|
||||
],
|
||||
};
|
||||
let out = layout_text(&child, &parent, 200);
|
||||
assert_eq!(out.matches("Employee").count(), 2, "two boxes:\n{out}");
|
||||
assert!(out.contains("manager_id ●"), "FK endpoint:\n{out}");
|
||||
assert!(out.contains('▶'), "connector:\n{out}");
|
||||
assert_snapshot!(out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_relationship_diagram_marks_all_compound_endpoints_from_data() {
|
||||
// The full App-side entry: build_diagram_table must mark BOTH
|
||||
// paired columns on each side from RelationshipDiagramData.
|
||||
let blank_rels = || (Vec::new(), Vec::new());
|
||||
let (r_out, r_in) = blank_rels();
|
||||
let region = TableDescription {
|
||||
name: "Region".to_string(),
|
||||
columns: vec![col("country", Type::Int, true, false), col("code", Type::Int, true, false)],
|
||||
outbound_relationships: r_out,
|
||||
inbound_relationships: r_in,
|
||||
indexes: Vec::new(),
|
||||
unique_constraints: Vec::new(),
|
||||
check_constraints: Vec::new(),
|
||||
};
|
||||
let (c_out, c_in) = blank_rels();
|
||||
let city = TableDescription {
|
||||
name: "City".to_string(),
|
||||
columns: vec![
|
||||
col("country", Type::Int, false, false),
|
||||
col("region_code", Type::Int, false, false),
|
||||
col("name", Type::Text, false, false),
|
||||
],
|
||||
outbound_relationships: c_out,
|
||||
inbound_relationships: c_in,
|
||||
indexes: Vec::new(),
|
||||
unique_constraints: Vec::new(),
|
||||
check_constraints: Vec::new(),
|
||||
};
|
||||
let data = crate::db::RelationshipDiagramData {
|
||||
rel: crate::persistence::RelationshipSchema {
|
||||
name: "city_region".to_string(),
|
||||
parent_table: "Region".to_string(),
|
||||
parent_columns: vec!["country".to_string(), "code".to_string()],
|
||||
child_table: "City".to_string(),
|
||||
child_columns: vec!["country".to_string(), "region_code".to_string()],
|
||||
on_delete: ReferentialAction::NoAction,
|
||||
on_update: ReferentialAction::NoAction,
|
||||
},
|
||||
child: city,
|
||||
parent: region,
|
||||
};
|
||||
let text = render_relationship_diagram(&data, 200, Mode::Simple)
|
||||
.iter()
|
||||
.map(|l| l.text.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(text.contains("region_code ●"), "child endpoint 2:\n{text}");
|
||||
assert!(text.contains("(PK) ●"), "parent endpoint is PK + marked:\n{text}");
|
||||
assert!(
|
||||
text.contains("(country, region_code) ▶ Region.(country, code)"),
|
||||
"pairing line:\n{text}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_structure_with_diagrams_replaces_prose_with_compact_diagrams() {
|
||||
let desc = TableDescription {
|
||||
|
||||
+13
@@ -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
|
||||
+11
@@ -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
|
||||
@@ -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()],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user