Merge branch 'main' into website (V1 relationship visualization)
Brings main's relationship-visualization feature (ADR-0044 in the main
namespace) and Gitea-migration cleanup onto the website branch, so the
docs can be written against the new diagram output:
- show relationship <name> / show table <T> render two-table connector
diagrams (child-FK-left, parent-right, n…1 cardinality)
- compound-FK bus routing + pairing line
- ~2000 lines across src/{app,db,event,output_render,runtime,ui}.rs,
new insta snapshots, tests/it/{show_list,compound_fk}.rs
Merged clean — no conflicts. The prior commit moved the website ADR out
of docs/adr/ into its own namespace, so main's ADR-0044
(relationship-visualization) lands with no collision.
Tests on the merged tree: 2207 passed, 0 failed, 0 skipped
(1 ignored doctest, inherited from main).
This commit is contained in:
@@ -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 <T>` renders a
|
||||||
|
box-drawn structure table (columns / types / constraints / indexes),
|
||||||
|
and relationships appear **as prose** beneath it —
|
||||||
|
`References:` / `Referenced by:` blocks formatted `A.col → B.col`
|
||||||
|
(`output_render.rs render_structure`, per ADR-0016 §5). The same
|
||||||
|
prose appears in V5a's `show relationship <name>` detail view.
|
||||||
|
|
||||||
|
The open piece is the **visual** form: drawing a relationship as two
|
||||||
|
tables side by side joined by a connecting line, with cardinality and
|
||||||
|
referential actions. ADR-0016 deliberately deferred this (OOS-1) and
|
||||||
|
**pre-sized the structure renderer to compose two of itself**. This
|
||||||
|
ADR cashes that in.
|
||||||
|
|
||||||
|
Three facts from the current architecture shape the design:
|
||||||
|
|
||||||
|
1. **Rendering is App-side; the worker returns structured data.**
|
||||||
|
*(Corrected from the initial draft after direct verification — an
|
||||||
|
earlier survey had this backwards.)* The database worker (`db.rs`)
|
||||||
|
returns `TableDescription` / `DataResult`; the **App** (`app.rs`)
|
||||||
|
calls the `output_render.rs` helpers to format them into
|
||||||
|
`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 <kind> [<name>]` family: `do_show_one` / `show_list` build
|
||||||
|
prose `Vec<String>` **in the worker** from `RelationshipSchema` /
|
||||||
|
index metadata, carrying *no* `TableDescription` — so
|
||||||
|
`show relationship <name>` is the path that must be restructured
|
||||||
|
(§6). The output buffer is a flat `VecDeque<OutputLine>` — a
|
||||||
|
historical log of lines, not re-renderable widgets (live
|
||||||
|
re-rendering is V4 territory).
|
||||||
|
2. **Relationships are list-based (ADR-0043).** `RelationshipSchema`
|
||||||
|
/`RelationshipEnd` endpoints are `Vec<String>` column lists,
|
||||||
|
positionally paired. Single-column FKs are the one-element case;
|
||||||
|
the diagram must render compound FKs too.
|
||||||
|
3. **Only 1:n relationships exist.** Relationships are declared 1:n
|
||||||
|
(ADR-0013); m:n (requirement C4) is unbuilt, and there is no
|
||||||
|
UNIQUE-target 1:1. So cardinality is always **1 on the parent
|
||||||
|
(referenced) side, n on the child (referencing/FK) side**.
|
||||||
|
|
||||||
|
The user has chosen the design direction across three decisions
|
||||||
|
(recorded below as made): **visual style**, **where it appears**, and
|
||||||
|
**multi-relationship layout**.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### 1. Scope and trigger — diagrams where relationships are the subject
|
||||||
|
|
||||||
|
Relationship diagrams replace the prose relationship form on the
|
||||||
|
surfaces where **relationships are the subject of the command**; no
|
||||||
|
new command or sigil is added (ADR-0009: one sigil, keyword grammar).
|
||||||
|
This is the user-chosen **"relationship-relevant"** reach (over a
|
||||||
|
global replacement of every structure echo, or a show-commands-only
|
||||||
|
scope — see the DA pass).
|
||||||
|
|
||||||
|
Diagram surfaces:
|
||||||
|
|
||||||
|
- **`show relationship <name>`** (V5a) renders **one** diagram: the
|
||||||
|
parent and child tables as **full structure boxes** joined by a
|
||||||
|
connector. The canonical base unit.
|
||||||
|
- **`show table <T>`** keeps T's full structure box at the top
|
||||||
|
(unchanged from ADR-0016 §5), then a **Relationships** section:
|
||||||
|
**one compact connector diagram per relationship**, stacked
|
||||||
|
vertically (§4).
|
||||||
|
- **Relationship DDL auto-shows** — the structure echo after
|
||||||
|
`add 1:n relationship`, `drop relationship`, and a future
|
||||||
|
`modify relationship` (C3a) — render their relationships as
|
||||||
|
compact diagrams (§4), since the user just acted on a relationship.
|
||||||
|
|
||||||
|
Prose-retained surfaces (**unchanged** from ADR-0016 §5):
|
||||||
|
|
||||||
|
- **Incidental DDL auto-shows** — the structure echo after
|
||||||
|
`create table`, `add`/`drop`/`rename`/`change column`,
|
||||||
|
`add`/`drop index` — keep the terse `References:` /
|
||||||
|
`Referenced by:` prose. A simple `add column` on a heavily-related
|
||||||
|
table should not print a wall of diagrams.
|
||||||
|
|
||||||
|
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 <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
|
||||||
|
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<OutputLine>` — `full` selects full vs compact boxes;
|
||||||
|
internally decides side-by-side vs vertical (§3) from `width`;
|
||||||
|
routes the connector(s) including compound pairs (§2.4).
|
||||||
|
- A shared helper for the box title row + key markers.
|
||||||
|
- The two call paths supply `width` from the new
|
||||||
|
`App::last_output_width` (§3).
|
||||||
|
- **`show table <T>` (already App-side).** The focal
|
||||||
|
`TableDescription` reaches `app.rs` and is rendered by
|
||||||
|
`render_structure` today. `render_structure` is refactored to emit
|
||||||
|
the focal structure box, then a **Relationships** section built
|
||||||
|
from the focal description's `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 <name>` (must be restructured).** Its
|
||||||
|
worker path `do_show_one` currently returns prose `Vec<String>`
|
||||||
|
from a `RelationshipSchema` only. To draw the **full** diagram the
|
||||||
|
App needs **both** endpoint `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 <name>` and
|
||||||
|
`show table <T>` produce diagram output (not prose) end-to-end
|
||||||
|
through the worker.
|
||||||
|
- **Existing tests/code to update** (enumerated from a DA grep — the
|
||||||
|
supersession of ADR-0016 §5 is not abstract):
|
||||||
|
- `output_render.rs:121,135` — the `References:` / `Referenced by:`
|
||||||
|
prose-emitting code in `render_structure`;
|
||||||
|
- `output_render.rs:78` — the docstring deferral note (OOS-1);
|
||||||
|
- `output_render.rs:793` — unit test asserting `"Referenced by:"`;
|
||||||
|
- `src/snapshots/…render_structure_with_relationships.snap` — the
|
||||||
|
prose snapshot;
|
||||||
|
- `tests/it/walking_skeleton.rs:477,530` — integration asserts on
|
||||||
|
`"Referenced by:"` (and the comment at 433);
|
||||||
|
- `src/dsl/command.rs:984` — a comment referencing the prose (no
|
||||||
|
behaviour).
|
||||||
|
If the scope fork resolves to "all `render_structure` call sites,"
|
||||||
|
the DDL-echo snapshots for create-table / add-column / drop-index /
|
||||||
|
change-column auto-shows also churn — to be re-recorded with
|
||||||
|
`cargo insta`. No test is deleted to hide a regression; each change
|
||||||
|
is a deliberate format update.
|
||||||
|
|
||||||
|
### 8. Out of scope
|
||||||
|
|
||||||
|
- **OOS-1.** Live reflow-on-resize of already-rendered diagrams —
|
||||||
|
V4's re-renderable journal (requirement V4).
|
||||||
|
- **OOS-2.** Whole-database ER diagram / export (requirement V3).
|
||||||
|
- **OOS-3.** m:n relationships (requirement C4, unbuilt). Only the
|
||||||
|
existing 1:n form is rendered.
|
||||||
|
- **OOS-4.** ASCII fallback for terminals without box-drawing
|
||||||
|
(inherits ADR-0016 OOS-5).
|
||||||
|
- **OOS-5.** Per-cell colouring of column *data* values (ADR-0016
|
||||||
|
OOS-3 / NFR-5) — unrelated to relationship structure.
|
||||||
|
- **OOS-6.** Cell-level truncation with ellipsis (ADR-0016 OOS-4
|
||||||
|
stands).
|
||||||
|
- **OOS-7.** A user-configurable relationship-display setting
|
||||||
|
(always-prose / always-diagram / auto-by-width — useful for small
|
||||||
|
screens). The §1 per-command default is v1; the setting is a clean
|
||||||
|
later follow-up (user-flagged 2026-06-09).
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Requirement **V1** is fully satisfied: relationships render as
|
||||||
|
two-table connector diagrams in both `show relationship <name>`
|
||||||
|
(full) and `show table <T>` (stacked compact), superseding the
|
||||||
|
prose form.
|
||||||
|
- `output_render.rs` grows a relationship renderer that **composes**
|
||||||
|
the box primitives exactly as ADR-0016 anticipated; the worker→UI
|
||||||
|
show contract becomes styled-line-based, a small generalisation
|
||||||
|
reusable by other styled `show` output later.
|
||||||
|
- The worker gains an `available_width` input for `show` — the first
|
||||||
|
width-aware formatting in the codebase. The decision is a snapshot
|
||||||
|
at command time; no resize machinery is introduced.
|
||||||
|
- Compound FKs (ADR-0043) get their first dedicated visualization,
|
||||||
|
with explicit positional-pair labelling.
|
||||||
|
- The historical-log output model is preserved (no widget/reflow
|
||||||
|
model); V4 remains the home for a re-renderable journal.
|
||||||
|
- Box-drawing is required (no ASCII fallback), consistent with
|
||||||
|
ADR-0016.
|
||||||
|
|
||||||
|
## Devil's Advocate / runda pass (2026-06-09)
|
||||||
|
|
||||||
|
A planning-time DA pass (empirical, against the code) corrected three
|
||||||
|
foundational errors carried in from an initial code survey, and
|
||||||
|
surfaced one open fork:
|
||||||
|
|
||||||
|
1. **Rendering boundary was inverted** (fixed in Context 1 / §6):
|
||||||
|
`render_structure` / `render_data_table` run **App-side**
|
||||||
|
(`app.rs`), not in the worker. Rendering, width, and theme are all
|
||||||
|
App-side — simpler than the draft assumed.
|
||||||
|
2. **Width was claimed "already tracked"; it is not** (fixed in §3):
|
||||||
|
`note_output_viewport` records only rows. A new
|
||||||
|
`App::last_output_width` (set from `ui.rs`) is required.
|
||||||
|
3. **`show relationship <name>` renders prose in the worker** (fixed
|
||||||
|
in §6): `do_show_one` carries only a `RelationshipSchema`. The
|
||||||
|
detail reply must be upgraded to include both endpoint
|
||||||
|
`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.
|
||||||
@@ -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-0041 — Copy the output panel to the system clipboard](0041-copy-output-to-clipboard.md) — **Accepted 2026-06-02 (issue #11)**, amends ADR-0003's app-command registry (adds **`copy`** / `copy all` / `copy last`). The friction it removes: filing a bug report meant terminal-selecting the output panel and fighting wrapping/borders. New **app-level command** (sigil-free, both modes): `copy` / `copy all` copy the whole panel; `copy last` copies from the most recent echo line to the end. **Mechanism — OSC 52 *and* native (`arboard`), always both**, because OSC 52 acceptance is undetectable (no terminal ack), so a true "fall back when unsupported" can't be built: emit the OSC 52 escape (no new dep — `base64`+`crossterm`; works over SSH; tmux-passthrough-wrapped via `$TMUX`), then a best-effort native write whose failure is ignored (headless host — OSC 52 carried it); the two carry identical content. **Format — plain text verbatim as rendered** (tags, `✓`/`✗`, box-drawing) joined by `\n`, without viewport padding/wrapping; a drift-lock test pins `OutputLine::plain_text` to `render_output_line`. `arboard` added **`--no-default-features`** (drops the `image` crate; X11-only on Linux — `wayland-data-control` deliberately omitted as it ~doubles the dep tree and OSC 52 covers native-Wayland). Security: write-only, scans clean for arboard's tree (cargo audit / osv-scanner / grype), 1Password-maintained, minimal surface. OOS: Markdown export, selection/range, a keybinding, OSC 52 read, `screen` passthrough
|
||||||
- [ADR-0042 — H1a parse-error pedagogy in the grammar-tree era](0042-h1a-parse-error-pedagogy-grammar-tree.md) — **Accepted 2026-06-03.** Continues **H1a** from ADR-0021 against the ADR-0024 grammar tree (ADR-0021's chumsky mechanism is dead). Records the **baseline already shipped** — per-command `usage:` block (38 `parse.usage.*` templates), available-commands fallback, structural "after `…`, expected …" wording, source-derived ident slot labels ("table name"/"column name"), curated `parse.custom.*` near-miss messages, and the ADR-0027/0033/0036 schema-aware `[ERR]` diagnostics — so H1a is *substantially* delivered at the intent level. Defines the remaining work as **(1)** a verified per-command **near-miss matrix** (`tests/typing_surface/` + `tests/it/parse_error_pedagogy.rs`) as the definition of done, test-first; **(2)** **friendlier literal expectation labels** — optional prose glosses on `Word`/`Punct`/`Flag` positions that *add* role context while always keeping the exact literal visible (e.g. "a filter clause: `where …` or `--all-rows`"); **(3)** **advanced-mode SQL** near-miss parity (RETURNING scope, CTE-arity positioning, `CROSS JOIN … ON`, INSERT…SELECT count) — **in scope**, kept distinct from ADR-0019 §OOS-2 which covers advanced-SQL *engine*-error sanitisation, a different layer. Catalog/anchor-phrase discipline (ADR-0019) preserved; no public API change. OOS: I3/I4, spell-correction, multi-error reporting, verbosity-gating the usage block
|
- [ADR-0042 — H1a parse-error pedagogy in the grammar-tree era](0042-h1a-parse-error-pedagogy-grammar-tree.md) — **Accepted 2026-06-03.** Continues **H1a** from ADR-0021 against the ADR-0024 grammar tree (ADR-0021's chumsky mechanism is dead). Records the **baseline already shipped** — per-command `usage:` block (38 `parse.usage.*` templates), available-commands fallback, structural "after `…`, expected …" wording, source-derived ident slot labels ("table name"/"column name"), curated `parse.custom.*` near-miss messages, and the ADR-0027/0033/0036 schema-aware `[ERR]` diagnostics — so H1a is *substantially* delivered at the intent level. Defines the remaining work as **(1)** a verified per-command **near-miss matrix** (`tests/typing_surface/` + `tests/it/parse_error_pedagogy.rs`) as the definition of done, test-first; **(2)** **friendlier literal expectation labels** — optional prose glosses on `Word`/`Punct`/`Flag` positions that *add* role context while always keeping the exact literal visible (e.g. "a filter clause: `where …` or `--all-rows`"); **(3)** **advanced-mode SQL** near-miss parity (RETURNING scope, CTE-arity positioning, `CROSS JOIN … ON`, INSERT…SELECT count) — **in scope**, kept distinct from ADR-0019 §OOS-2 which covers advanced-SQL *engine*-error sanitisation, a different layer. Catalog/anchor-phrase discipline (ADR-0019) preserved; no public API change. OOS: I3/I4, spell-correction, multi-error reporting, verbosity-gating the usage block
|
||||||
- [ADR-0043 — Compound-primary-key foreign-key references (T3)](0043-compound-pk-foreign-key-references.md) — **Accepted + implemented 2026-06-09** (all four forks confirmed at the recommended option: full-PK matching, house-style uniform lists, parenthesized DSL syntax, bare-SQL-FK auto-expansion). Closes `requirements.md` **T3** `[x]` — the relationship model went list-based across six layers (single-column preserved, no migration), DSL `from P.(a,b) to C.(x,y)` + SQL `FOREIGN KEY (a,b) REFERENCES P(x,y)` parse/execute/enforce, 12 tests in `tests/it/compound_fk.rs`. Closes the open leg of `requirements.md` **T3**: a foreign key that *references* a parent's compound primary key. A 2026-06-09 audit found single-column FK woven through ~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-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; 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)
|
||||||
|
|||||||
@@ -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 <name> 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 <name>`, `show table <T>`, 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 <name>` 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.
|
||||||
+15
-11
@@ -423,20 +423,24 @@ since ADR-0027.)
|
|||||||
|
|
||||||
## Visualizations
|
## Visualizations
|
||||||
|
|
||||||
- [/] **V1** Single-element views render in the output pane: a
|
- [x] **V1** Single-element views render in the output pane: a
|
||||||
selected table as its structure (columns, types, keys,
|
selected table as its structure (columns, types, keys,
|
||||||
constraints); a selected relationship as two tables joined by
|
constraints); a selected relationship as two tables joined by
|
||||||
a line.
|
a line.
|
||||||
*(Partial, verified 2026-06-07: the **table-structure** half is
|
*(Done 2026-06-10 — **ADR-0044**. The table-structure half shipped
|
||||||
done — `output_render.rs:82-180` renders columns / types /
|
earlier; the **relationship-as-line-art** half (ADR-0016 OOS-1) now
|
||||||
constraints / indexes in a box-drawing table, with relationship
|
renders as **Style-A two-table connector diagrams** wherever a
|
||||||
metadata as `References:` / `Referenced by:` prose
|
relationship is the subject: `show relationship <name>` (full
|
||||||
(`A.col → B.col`). The **relationship-as-line-art** half — two
|
structure boxes), `show table <T>` and `add`/`drop relationship`
|
||||||
tables drawn side by side with a connecting line — is **not
|
echoes (focal box + compact stacked diagrams). Child-left /
|
||||||
implemented** (deferred per `output_render.rs` §5 OOS-1, ADR
|
parent-right, `n…1` cardinality, referential actions, bold title
|
||||||
pending). This is the relationship-visualisation piece that has
|
rows; rendered App-side and **width-aware** (side-by-side ↔ vertical
|
||||||
been repeatedly pushed away; it is the substantive open part of
|
fallback). **Compound** FKs route a shared bus + an explicit
|
||||||
V1. Selection-nav and the broader journal direction live in V4.)*
|
`(a, b) ▶ P.(x, y)` pairing line; **self-referential** FKs draw two
|
||||||
|
same-named boxes. Incidental DDL echoes keep the prose form (the
|
||||||
|
"relationship-relevant" reach). The §3 last-resort helper line was
|
||||||
|
considered and rejected. Two `/runda` passes (design + implementation).
|
||||||
|
Selection-nav and the broader journal direction remain in V4.)*
|
||||||
- [/] **V2** SQL query results render as a dynamic table view in
|
- [/] **V2** SQL query results render as a dynamic table view in
|
||||||
the output pane, with multiple result tabs supported.
|
the output pane, with multiple result tabs supported.
|
||||||
*(Partial, verified 2026-06-07: the **table view** is done —
|
*(Partial, verified 2026-06-07: the **table view** is done —
|
||||||
|
|||||||
+74
-2
@@ -76,6 +76,19 @@ pub enum OutputStyleClass {
|
|||||||
/// every `[client-side]` category-3 prose note (ADR-0038 §6).
|
/// every `[client-side]` category-3 prose note (ADR-0038 §6).
|
||||||
/// Resolves to `theme.muted`.
|
/// Resolves to `theme.muted`.
|
||||||
Hint,
|
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
|
/// 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
|
/// logical OutputLines. Required for accurate scroll capping
|
||||||
/// when long lines wrap to multiple display rows.
|
/// when long lines wrap to multiple display rows.
|
||||||
pub last_output_total_wrapped: usize,
|
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,
|
/// Prettified display name of the currently-open project,
|
||||||
/// rendered in the status bar (P-NAME-3, ADR-0015 §2). `None`
|
/// rendered in the status bar (P-NAME-3, ADR-0015 §2). `None`
|
||||||
/// during very-early startup before the runtime has opened a
|
/// during very-early startup before the runtime has opened a
|
||||||
@@ -432,6 +450,7 @@ impl App {
|
|||||||
output_scroll: 0,
|
output_scroll: 0,
|
||||||
last_output_visible: 0,
|
last_output_visible: 0,
|
||||||
last_output_total_wrapped: 0,
|
last_output_total_wrapped: 0,
|
||||||
|
last_output_width: 80,
|
||||||
project_name: None,
|
project_name: None,
|
||||||
project_is_temp: false,
|
project_is_temp: false,
|
||||||
fatal_message: None,
|
fatal_message: None,
|
||||||
@@ -614,6 +633,10 @@ impl App {
|
|||||||
}
|
}
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
AppEvent::DslShowRelationshipSucceeded { command, data } => {
|
||||||
|
self.handle_dsl_show_relationship_success(&command, data.as_ref());
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
AppEvent::DslInsertSucceeded { command, result } => {
|
AppEvent::DslInsertSucceeded { command, result } => {
|
||||||
self.handle_dsl_insert_success(&command, &result);
|
self.handle_dsl_insert_success(&command, &result);
|
||||||
Vec::new()
|
Vec::new()
|
||||||
@@ -1666,8 +1689,28 @@ impl App {
|
|||||||
fn handle_dsl_success(&mut self, command: &Command, description: Option<TableDescription>) {
|
fn handle_dsl_success(&mut self, command: &Command, description: Option<TableDescription>) {
|
||||||
self.note_ok_summary(command);
|
self.note_ok_summary(command);
|
||||||
if let Some(desc) = description.as_ref() {
|
if let Some(desc) = description.as_ref() {
|
||||||
for line in crate::output_render::render_structure(desc) {
|
// ADR-0044 §1 "relationship-relevant" reach: when a
|
||||||
self.note_system(line);
|
// 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;
|
self.current_table = description;
|
||||||
@@ -1694,6 +1737,35 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `show relationship <name>` (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) {
|
fn handle_dsl_insert_success(&mut self, command: &Command, result: &InsertResult) {
|
||||||
self.note_ok_summary(command);
|
self.note_ok_summary(command);
|
||||||
self.note_system(crate::t!("ok.rows_inserted", count = result.rows_affected));
|
self.note_system(crate::t!("ok.rows_inserted", count = result.rows_affected));
|
||||||
|
|||||||
@@ -80,6 +80,21 @@ pub struct TableDescription {
|
|||||||
pub check_constraints: Vec<crate::persistence::TableCheck>,
|
pub check_constraints: Vec<crate::persistence::TableCheck>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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).
|
/// One user-created index on a table (ADR-0025).
|
||||||
///
|
///
|
||||||
/// Read live from the engine's native catalog
|
/// Read live from the engine's native catalog
|
||||||
@@ -566,6 +581,13 @@ enum Request {
|
|||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
reply: oneshot::Sender<Result<Vec<String>, DbError>>,
|
reply: oneshot::Sender<Result<Vec<String>, 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<Result<Option<RelationshipDiagramData>, DbError>>,
|
||||||
|
},
|
||||||
DescribeTable {
|
DescribeTable {
|
||||||
name: String,
|
name: String,
|
||||||
source: Option<String>,
|
source: Option<String>,
|
||||||
@@ -1341,6 +1363,18 @@ impl Database {
|
|||||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
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<Option<RelationshipDiagramData>, DbError> {
|
||||||
|
let (reply, recv) = oneshot::channel();
|
||||||
|
self.send(Request::ShowRelationship { name, reply }).await?;
|
||||||
|
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn describe_table(
|
pub async fn describe_table(
|
||||||
&self,
|
&self,
|
||||||
name: String,
|
name: String,
|
||||||
@@ -2272,6 +2306,9 @@ fn handle_request(
|
|||||||
Request::ShowList { kind, name, reply } => {
|
Request::ShowList { kind, name, reply } => {
|
||||||
let _ = reply.send(do_show_list(conn, kind, name.as_deref()));
|
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 {
|
Request::DescribeTable {
|
||||||
name,
|
name,
|
||||||
source,
|
source,
|
||||||
@@ -5870,6 +5907,25 @@ fn do_list_tables(conn: &Connection) -> Result<Vec<String>, DbError> {
|
|||||||
Ok(out)
|
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<Option<RelationshipDiagramData>, 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 <kind>` list commands
|
/// Pre-formatted display lines for the `show <kind>` list commands
|
||||||
/// (V5). A count header followed by one indented item per line, or a
|
/// (V5). A count header followed by one indented item per line, or a
|
||||||
/// single friendly "none yet" line for an empty collection. Reuses
|
/// 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
|
/// labelled block, or a friendly "no such item" line. `Tables` is
|
||||||
/// never routed here (the table singular is `ShowTable`); the
|
/// never routed here (the table singular is `ShowTable`); the
|
||||||
/// defensive arm keeps the match total without a panic.
|
/// defensive arm keeps the match total without a panic.
|
||||||
|
///
|
||||||
|
/// **The `Relationships` arm is superseded for the app by
|
||||||
|
/// `do_show_relationship` (ADR-0044): the runtime reroutes a named
|
||||||
|
/// `show relationship` to the structured diagram path, so this prose
|
||||||
|
/// form is no longer shown to users.** It is retained — reachable via
|
||||||
|
/// the `Database::show_list` worker API and covered by a worker test —
|
||||||
|
/// as a text fallback that could back a future non-visual display
|
||||||
|
/// option (cf. ADR-0044 OOS-7's relationship-display setting). The
|
||||||
|
/// `Indexes` arm remains live (`show index <name>` still routes here).
|
||||||
fn do_show_one(
|
fn do_show_one(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
kind: crate::dsl::command::ShowListKind,
|
kind: crate::dsl::command::ShowListKind,
|
||||||
|
|||||||
+8
-1
@@ -9,7 +9,8 @@ use crossterm::event::KeyEvent;
|
|||||||
|
|
||||||
use crate::db::{
|
use crate::db::{
|
||||||
AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult,
|
AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult,
|
||||||
DropColumnResult, InsertResult, QueryPlan, TableDescription, UpdateResult,
|
DropColumnResult, InsertResult, QueryPlan, RelationshipDiagramData, TableDescription,
|
||||||
|
UpdateResult,
|
||||||
};
|
};
|
||||||
use crate::dsl::Command;
|
use crate::dsl::Command;
|
||||||
|
|
||||||
@@ -76,6 +77,12 @@ pub enum AppEvent {
|
|||||||
/// A `show <kind>` list command (V5) — carries pre-formatted
|
/// A `show <kind>` list command (V5) — carries pre-formatted
|
||||||
/// display lines (tables / relationships / indexes).
|
/// display lines (tables / relationships / indexes).
|
||||||
DslShowListSucceeded { command: Command, lines: Vec<String> },
|
DslShowListSucceeded { command: Command, lines: Vec<String> },
|
||||||
|
/// `show relationship <name>` (ADR-0044) — structured data for the
|
||||||
|
/// diagram, rendered App-side; `None` when no such relationship.
|
||||||
|
DslShowRelationshipSucceeded {
|
||||||
|
command: Command,
|
||||||
|
data: Option<RelationshipDiagramData>,
|
||||||
|
},
|
||||||
DslInsertSucceeded {
|
DslInsertSucceeded {
|
||||||
command: Command,
|
command: Command,
|
||||||
result: InsertResult,
|
result: InsertResult,
|
||||||
|
|||||||
+846
-26
@@ -90,9 +90,18 @@ fn cols_disp(cols: &[String]) -> String {
|
|||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn render_structure(desc: &TableDescription) -> Vec<String> {
|
pub fn render_structure(desc: &TableDescription) -> Vec<String> {
|
||||||
let mut out: Vec<String> = Vec::new();
|
let mut out = structure_box_lines(desc);
|
||||||
out.push(desc.name.clone());
|
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<String> {
|
||||||
|
let mut out: Vec<String> = vec![desc.name.clone()];
|
||||||
let header_cells = vec![
|
let header_cells = vec![
|
||||||
"Name".to_string(),
|
"Name".to_string(),
|
||||||
"Type".to_string(),
|
"Type".to_string(),
|
||||||
@@ -101,22 +110,18 @@ pub fn render_structure(desc: &TableDescription) -> Vec<String> {
|
|||||||
let body: Vec<Vec<String>> = desc
|
let body: Vec<Vec<String>> = desc
|
||||||
.columns
|
.columns
|
||||||
.iter()
|
.iter()
|
||||||
.map(|c| {
|
.map(|c| vec![c.name.clone(), type_display(c), constraints_display(c)])
|
||||||
vec![
|
|
||||||
c.name.clone(),
|
|
||||||
type_display(c),
|
|
||||||
constraints_display(c),
|
|
||||||
]
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
// Type column gets the same numeric/text rule as data
|
// Every cell is a keyword/text string, so left-align throughout.
|
||||||
// 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.
|
|
||||||
let alignments = vec![Alignment::Left, Alignment::Left, Alignment::Left];
|
let alignments = vec![Alignment::Left, Alignment::Left, Alignment::Left];
|
||||||
out.extend(render_table(&header_cells, &body, &alignments));
|
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<String> {
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
if !desc.outbound_relationships.is_empty() {
|
if !desc.outbound_relationships.is_empty() {
|
||||||
out.push("References:".to_string());
|
out.push("References:".to_string());
|
||||||
for r in &desc.outbound_relationships {
|
for r in &desc.outbound_relationships {
|
||||||
@@ -145,11 +150,14 @@ pub fn render_structure(desc: &TableDescription) -> Vec<String> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
// Indexes section (ADR-0025), shown only when the table
|
/// Indexes section (ADR-0025), only when the table carries a
|
||||||
// carries at least one user-created index. A UNIQUE index is
|
/// user-created index. A UNIQUE index is marked `[unique]` (ADR-0035
|
||||||
// marked `[unique]` so a learner can tell a uniqueness-enforcing
|
/// §4d).
|
||||||
// index from a performance-only one (ADR-0035 §4d).
|
fn index_lines(desc: &TableDescription) -> Vec<String> {
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
if !desc.indexes.is_empty() {
|
if !desc.indexes.is_empty() {
|
||||||
out.push("Indexes:".to_string());
|
out.push("Indexes:".to_string());
|
||||||
for index in &desc.indexes {
|
for index in &desc.indexes {
|
||||||
@@ -161,17 +169,18 @@ pub fn render_structure(desc: &TableDescription) -> Vec<String> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
// Table-level constraints (ADR-0035 §4i b): composite `UNIQUE (a, b)`
|
/// Table-level constraints (ADR-0035 §4i b): composite `UNIQUE (a, b)`
|
||||||
// and table `CHECK (…)` constraints. Single-column UNIQUE / NOT NULL /
|
/// and table `CHECK (…)`. Column-level constraints already show in the
|
||||||
// PK / column-level CHECK already show in the per-column "Constraints"
|
/// per-column "Constraints" column; this is the multi-column / named
|
||||||
// column above; this section is the table-level constraints that span
|
/// set, each with its addressable name where it has one.
|
||||||
// columns or stand alone. A named CHECK shows its name.
|
fn constraint_lines(desc: &TableDescription) -> Vec<String> {
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
if !desc.unique_constraints.is_empty() || !desc.check_constraints.is_empty() {
|
if !desc.unique_constraints.is_empty() || !desc.check_constraints.is_empty() {
|
||||||
out.push("Table constraints:".to_string());
|
out.push("Table constraints:".to_string());
|
||||||
for cols in &desc.unique_constraints {
|
for cols in &desc.unique_constraints {
|
||||||
// Annotate with the derived, addressable name (ADR-0035
|
|
||||||
// Amendment 1) so the user can `drop constraint <name>`.
|
|
||||||
out.push(format!(
|
out.push(format!(
|
||||||
" {}: unique ({})",
|
" {}: unique ({})",
|
||||||
crate::db::unique_constraint_name(cols),
|
crate::db::unique_constraint_name(cols),
|
||||||
@@ -185,7 +194,6 @@ pub fn render_structure(desc: &TableDescription) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,6 +492,9 @@ enum BorderRow {
|
|||||||
Top,
|
Top,
|
||||||
HeaderUnderline,
|
HeaderUnderline,
|
||||||
Bottom,
|
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 {
|
fn border_row(widths: &[usize], kind: BorderRow) -> String {
|
||||||
@@ -491,6 +502,7 @@ fn border_row(widths: &[usize], kind: BorderRow) -> String {
|
|||||||
BorderRow::Top => ('┌', '┬', '┐'),
|
BorderRow::Top => ('┌', '┬', '┐'),
|
||||||
BorderRow::HeaderUnderline => ('├', '┼', '┤'),
|
BorderRow::HeaderUnderline => ('├', '┼', '┤'),
|
||||||
BorderRow::Bottom => ('└', '┴', '┘'),
|
BorderRow::Bottom => ('└', '┴', '┘'),
|
||||||
|
BorderRow::TitleUnderline => ('├', '┬', '┤'),
|
||||||
};
|
};
|
||||||
let mut s = String::new();
|
let mut s = String::new();
|
||||||
s.push(left);
|
s.push(left);
|
||||||
@@ -540,6 +552,565 @@ fn content_row(cells: &[String], widths: &[usize], alignments: &[Alignment]) ->
|
|||||||
s
|
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<String>,
|
||||||
|
/// 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<DiagramCol>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<OutputSpan>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Seg>,
|
||||||
|
width: usize,
|
||||||
|
endpoint_rows: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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::<usize>() + (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<Seg> = 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<usize>) -> 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<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 + 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<Seg> {
|
||||||
|
let indent = " ";
|
||||||
|
let mut out: Vec<Seg> = 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<Seg> {
|
||||||
|
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 <name>` 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<OutputLine> {
|
||||||
|
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<Seg> {
|
||||||
|
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 <T>` 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<OutputLine> {
|
||||||
|
let mut out: Vec<OutputLine> = 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -547,6 +1118,255 @@ mod tests {
|
|||||||
use crate::dsl::ReferentialAction;
|
use crate::dsl::ReferentialAction;
|
||||||
use insta::assert_snapshot;
|
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::<Vec<_>>()
|
||||||
|
.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::<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 {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.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 {
|
fn col(name: &str, ty: Type, pk: bool, notnull: bool) -> ColumnDescription {
|
||||||
ColumnDescription {
|
ColumnDescription {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
|
|||||||
@@ -1407,6 +1407,12 @@ fn spawn_dsl_dispatch(
|
|||||||
command: command.clone(),
|
command: command.clone(),
|
||||||
lines,
|
lines,
|
||||||
},
|
},
|
||||||
|
Ok(CommandOutcome::ShowRelationship(data)) => {
|
||||||
|
AppEvent::DslShowRelationshipSucceeded {
|
||||||
|
command: command.clone(),
|
||||||
|
data: data.map(|b| *b),
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(CommandOutcome::QueryPlan(plan)) => AppEvent::DslExplainSucceeded {
|
Ok(CommandOutcome::QueryPlan(plan)) => AppEvent::DslExplainSucceeded {
|
||||||
command: command.clone(),
|
command: command.clone(),
|
||||||
plan,
|
plan,
|
||||||
@@ -2252,6 +2258,10 @@ enum CommandOutcome {
|
|||||||
/// the worker (table / relationship / index names). Pure
|
/// the worker (table / relationship / index names). Pure
|
||||||
/// display, no schema change.
|
/// display, no schema change.
|
||||||
ShowList(Vec<String>),
|
ShowList(Vec<String>),
|
||||||
|
/// 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<Box<crate::db::RelationshipDiagramData>>),
|
||||||
QueryPlan(QueryPlan),
|
QueryPlan(QueryPlan),
|
||||||
Insert(InsertResult),
|
Insert(InsertResult),
|
||||||
Update(UpdateResult),
|
Update(UpdateResult),
|
||||||
@@ -2774,6 +2784,16 @@ async fn execute_command_typed(
|
|||||||
.describe_table(name, src)
|
.describe_table(name, src)
|
||||||
.await
|
.await
|
||||||
.map(|d| CommandOutcome::Schema(Some(d))),
|
.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 <kind>` 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
|
Command::ShowList { kind, name } => database
|
||||||
.show_list(kind, name)
|
.show_list(kind, name)
|
||||||
.await
|
.await
|
||||||
|
|||||||
+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
|
||||||
+12
@@ -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
|
||||||
+17
@@ -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
|
||||||
@@ -667,6 +667,9 @@ fn render_output_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area
|
|||||||
// mutable `note_output_viewport` call below).
|
// mutable `note_output_viewport` call below).
|
||||||
let total_wrapped = approximate_wrapped_rows_from_output(&app.output, inner.width);
|
let total_wrapped = approximate_wrapped_rows_from_output(&app.output, inner.width);
|
||||||
app.note_output_viewport(visible, total_wrapped);
|
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<Line<'_>> = app
|
let lines: Vec<Line<'_>> = app
|
||||||
.output
|
.output
|
||||||
@@ -756,6 +759,19 @@ const fn output_span_style(class: OutputStyleClass, theme: &Theme) -> Style {
|
|||||||
// existing `client_side.*` notes). `theme.muted` is the
|
// existing `client_side.*` notes). `theme.muted` is the
|
||||||
// established dim foreground.
|
// established dim foreground.
|
||||||
OutputStyleClass::Hint => Style::new().fg(theme.muted),
|
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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -547,3 +547,45 @@ fn compound_fk_partial_pk_reference_is_refused() {
|
|||||||
assert!(err.is_err(), "a partial-PK reference must be refused (F-A)");
|
assert!(err.is_err(), "a partial-PK reference must be refused (F-A)");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn show_relationship_carries_compound_columns_into_diagram_data() {
|
||||||
|
// ADR-0044 §2.4: the `show relationship` diagram payload carries
|
||||||
|
// both paired columns on each side so the renderer can route the
|
||||||
|
// bus + pairing line.
|
||||||
|
let (_p, db, _dir) = open_project_db();
|
||||||
|
let rt = rt();
|
||||||
|
rt.block_on(async {
|
||||||
|
seed_compound(&db).await;
|
||||||
|
db.add_relationship(
|
||||||
|
Some("city_region".to_string()),
|
||||||
|
"Region".to_string(),
|
||||||
|
vec!["country".to_string(), "code".to_string()],
|
||||||
|
"City".to_string(),
|
||||||
|
vec!["country".to_string(), "region_code".to_string()],
|
||||||
|
ReferentialAction::NoAction,
|
||||||
|
ReferentialAction::NoAction,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("add compound relationship");
|
||||||
|
|
||||||
|
let data = db
|
||||||
|
.show_relationship("city_region".to_string())
|
||||||
|
.await
|
||||||
|
.expect("ok")
|
||||||
|
.expect("found");
|
||||||
|
// child = FK holder (City), parent = referenced (Region).
|
||||||
|
assert_eq!(data.child.name, "City");
|
||||||
|
assert_eq!(data.parent.name, "Region");
|
||||||
|
assert_eq!(
|
||||||
|
data.rel.child_columns,
|
||||||
|
vec!["country".to_string(), "region_code".to_string()],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
data.rel.parent_columns,
|
||||||
|
vec!["country".to_string(), "code".to_string()],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -357,3 +357,135 @@ fn app_renders_show_list_lines_as_system_output() {
|
|||||||
"item line rendered",
|
"item line rendered",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// ADR-0044 — `show relationship <name>` 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::<Vec<_>>()
|
||||||
|
.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::<Vec<_>>()
|
||||||
|
.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}");
|
||||||
|
}
|
||||||
|
|||||||
@@ -473,9 +473,15 @@ fn add_relationship_flow_shows_parent_side_with_inbound_section() {
|
|||||||
echo: None,
|
echo: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
// Tall viewport so the [ok] echo line stays visible above the
|
||||||
assert!(rendered.contains("Referenced by:"), "{rendered}");
|
// (taller-than-prose) diagram for the endpoint-subject assertion.
|
||||||
assert!(rendered.contains("Orders.CustId"), "{rendered}");
|
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}");
|
assert!(rendered.contains("on delete cascade"), "{rendered}");
|
||||||
// The [ok] subject lists the endpoints. Long lines wrap in
|
// The [ok] subject lists the endpoints. Long lines wrap in
|
||||||
// the panel, so we check the first half of the phrase only.
|
// the panel, so we check the first half of the phrase only.
|
||||||
|
|||||||
Reference in New Issue
Block a user