Files
rdbms-playground/docs/adr/0045-mn-convenience.md
T
claude@clouddev1 8bd43ccadf feat: create m:n relationship convenience command (C4, ADR-0045)
`create m:n relationship from <T1> to <T2> [as <name>]` generates a
junction table with one FK column per parent PK column ({table}_{pkcol},
typed via fk_target_type), a compound PK over them, and two CASCADE 1:n
relationships -- all in one do_create_table call = one undo step.
Auto-named {T1}_{T2} (optional `as`), both modes, compound-parent PKs
supported (ADR-0043). Self-referential m:n / PK-less parent / internal
junction name / name collision all refused.

Wired across every surface: grammar (separate CREATE_M2N node), worker
executor, runtime dispatch, completion ("m:n" composite), hints,
highlighting, help + usage catalog + disambiguator, and the advanced-mode
DSL->SQL teaching echo (render_create_m2n, round-trips as valid SQL).

Generalized/fixed framework assumptions the build + two /runda passes
surfaced (all behaviour-preserving for existing commands):
- simple-mode dispatch committed simple.first() unconditionally -> tries
  candidates, so `create table` no longer shadows `create m:n`.
- the completion continuation-merge was advanced-only -> runs in simple
  mode too when an entry word has >1 DSL form (gated simple_count>1).
- do_create_table now rejects internal `__rdbms_*` names (closes a
  pre-existing hole on the DSL create-table path too, not just m:n).
- usage disambiguator now recognizes the `m:n` opener.

Tests: 14 integration (tests/it/m2n.rs), 7 typing-surface matrix, echo /
highlight / usage / internal-name units. Closes C4.
2237 pass / 0 fail / 1 ignored. Clippy clean.
2026-06-10 14:26:33 +00:00

301 lines
15 KiB
Markdown

# ADR-0045: `create m:n relationship` convenience command (C4)
## Status
Accepted (2026-06-10); **implemented 2026-06-10**. Closes
`requirements.md` **C4**.
**Implementation note — a corrected ADR premise.** The plan claimed
"the walker already dispatches multiple nodes per entry word" and used
that to justify a *separate* `CREATE_M2N` node. That is true only in
**advanced** mode. The build hit **two** places hard-coded to assume
**≤1 DSL form per entry word in simple mode**: (1) the dispatcher
(`decide`) committed `simple.first()` unconditionally, so `create
table` shadowed `create m:n`; (2) the completion continuation-merge was
gated `if mode == Advanced`, so simple mode never surfaced `m:n` as a
candidate. Both were generalized to support multiple DSL forms per
entry word — **behaviour-preserving for every existing single-form
command** (the dispatch reduces to the old single-candidate commit; the
completion merge is gated on `simple_count > 1`). Verified: zero ripple
beyond the new command's own surfaces. The teaching echo (advanced-mode
DSL→SQL, ADR-0038) was also wired: `render_create_m2n` emits the
generated `CREATE TABLE … FOREIGN KEY …` from the post-exec junction
description (round-trips as valid SQL).
A second `/runda` DA pass (pre-commit) closed five coverage gaps
(highlighting, persistence round-trip, junction rename, name-collision,
missing-parent — the first two had been wrongly claimed verified) and
found two more issues: (a) `create m:n … as __rdbms_*` was accepted —
a hidden-orphan hole the new `as` slot **exposed**, but rooted in the
simple-mode `TABLE_NAME_NEW` slot having no internal-name guard (so
plain `create table __rdbms_*` had it too). Fixed at the **root**
a `reject_internal_table_name` guard in the shared `do_create_table`,
closing every path (the advanced-SQL path already rejected at parse).
(b) the usage disambiguator (`usage_key_for_input`) handled the `1:n`
opener but not `m:n`, so `create m:n …` resolved to no usage form —
fixed with an explicit `m:n` branch. All four
design forks were escalated and user-confirmed at the recommended
option (compound-over-FKs junction PK; `CASCADE` actions; auto-name +
optional `as`; both modes). Two follow-up points were also confirmed
in a `/runda` DA pass: **self-referential m:n is refused outright**
(user: "refuse — full stop"; it is a beginner-facing convenience, not
the place for directional-naming complexity), and the FK column naming
is **`{parent_table}_{pk_column}`**. The DA pass additionally
established — against the user's initial assumption — that **PK-less
tables *are* reachable** (advanced-mode SQL `create table t (a int)`
declares no PK; `sql_create_table.rs` asserts `pk.is_empty()`), so the
parent-PK guard (D7) is retained as a correctness check.
Builds on ADR-0013 (named 1:n relationships, the relationship
metadata table, the rebuild-table primitive), ADR-0043 (compound,
list-based FK references — the junction may reference compound parent
PKs), ADR-0011 (`Type::fk_target_type()` for FK column typing), and
the existing `do_create_table` executor (which already accepts
`foreign_keys: Vec<SqlForeignKey>` and writes relationship metadata
per FK). Honours ADR-0003 (mode model), ADR-0009 (DSL conventions),
ADR-0002 (no engine name in user-facing strings), and ADR-0024
(unified grammar / `CommandNode` registration, completion, hints,
help-id, usage-id wiring).
## Context
A many-to-many relationship is modelled in a relational database by a
**junction table** (a.k.a. associative / bridge table) that holds one
foreign key to each of the two parents. Today a learner can build this
by hand: `create table` the junction, then `add 1:n relationship`
twice. That is three commands and requires the learner to already know
the junction-table pattern — exactly the concept C4 is meant to
*teach*.
C4 (`requirements.md`): *"`create m:n relationship from <T1> to <T2>`
produces an auto-named junction table the user can rename; pulls
primary keys and FK definitions automatically."*
The relationship machinery this builds on is freshly solid: ADR-0043
made the relationship model list-based (compound-aware) across six
layers, and ADR-0044 gave relationships a visual representation. C4 is
the natural convenience layer on top.
## Decision
Add a DSL command:
```
create m:n relationship from <T1> to <T2> [as <name>]
```
It generates a **junction table** with one FK column per primary-key
column of each parent, a **compound primary key** over all of those FK
columns, and **two 1:n relationships** (junction → T1, junction → T2),
all in a single transaction (= one undo step). The junction is a
normal table: `rename table`, `drop table`, `show table`, `insert`,
etc. all work on it afterward.
### D1 — Junction primary key: compound over the FK columns (fork, user-chosen)
The junction's PK is the **combination of all its FK columns**. For
`create m:n relationship from Students to Courses` (both PK `id`):
```
Students_Courses
Students_id int ┐ PRIMARY KEY (Students_id, Courses_id)
Courses_id int ┘
FOREIGN KEY (Students_id) REFERENCES Students(id)
FOREIGN KEY (Courses_id) REFERENCES Courses(id)
```
This is the textbook junction: the `(Students_id, Courses_id)` pair is
unique, so a student cannot be linked to the same course twice. It is
the most pedagogically correct model and needs no surrogate key.
*Rejected:* a surrogate `serial` PK + `UNIQUE` over the FK pair (adds a
key the learner did not ask for); two FK columns with no PK (allows
duplicate links — wrong lesson).
### D2 — Referential actions: `CASCADE` on delete and update (fork, user-chosen)
Both generated FKs default to `ON DELETE CASCADE ON UPDATE CASCADE`. A
junction row is meaningless without both ends, so deleting a parent
(a Student or a Course) removes its link rows automatically — the
natural junction semantics, and a clean teaching demonstration of
cascade. There is no syntax in this command to override the actions;
the learner who wants different actions builds the junction by hand
(or drops + re-adds a relationship). This keeps the convenience
command convenient.
### D3 — Naming: auto-name `<T1>_<T2>`, optional `as <name>` (fork, user-chosen)
The junction table is auto-named `{T1}_{T2}` (e.g. `Students_Courses`).
An optional `as <name>` clause overrides it — consistent with
`add 1:n relationship [as <name>]` and saving a follow-up
`rename table`. The two generated relationships are auto-named by the
existing relationship-name resolver (e.g.
`Students_id_to_Students_Courses_Students_id`), exactly as `create
table` with inline FKs already names them.
### D4 — FK column naming: `{parent_table}_{pk_column}`
Each FK column is named `{parent_table}_{pk_column}` — one per PK
column of each parent. This disambiguates the common case where both
parents share a PK column name (both `id``Students_id`,
`Courses_id`), and generalises to compound parent PKs: a parent
`Sections(course_id, term)` contributes `Sections_course_id` and
`Sections_term`. Column types come from each parent PK column's
`Type::fk_target_type()` (ADR-0011): `serial → int`, `shortid → text`,
others identity — so the junction columns are plain storable types,
never auto-generating.
### D5 — Mode availability: both simple and advanced
`create m:n relationship` is a `CommandCategory::Simple` DSL command,
reachable in **both** input modes — the same posture as the sibling
relationship commands (`drop relationship … Mode::Advanced` is a tested
path today). There is no SQL spelling for it; an advanced-mode user who
prefers raw SQL still has `CREATE TABLE` + `FOREIGN KEY`. The command
is purely additive teaching sugar, so making it available everywhere is
harmless and consistent.
### D6 — Implementation: one `do_create_table` call, not a batch
The junction table and both relationships are created by **building a
single `Command`/worker request that `do_create_table` already
handles**: `do_create_table` accepts `columns`, `primary_key`, and
`foreign_keys: Vec<SqlForeignKey>`, and already inserts relationship
metadata for each FK. So the executor:
1. Canonicalises `T1`, `T2` (`require_canonical_table`) and reads each
parent's PK (`read_schema().primary_key`). **D7 — parent-PK guard:**
if either parent's `primary_key` is empty, error with a friendly
"`<table>` has no primary key, so it cannot anchor an m:n
relationship" *before* building any FK. This is a real case — a
table created via advanced-mode SQL `create table t (a int)` has no
PK (`sql_create_table.rs` asserts `pk.is_empty()` for that form) —
not a theoretical one, so the guard is a correctness requirement,
not defensive padding.
2. Builds the junction `ColumnSpec`s (one per parent PK column, typed
via `fk_target_type`), the compound `primary_key` list, and two
`SqlForeignKey` values (`on_delete = on_update = Cascade`).
3. Calls the create-table path, which creates the table + both FKs +
all metadata in **one transaction** — naturally one undo step, no
`BeginBatch`/`EndBatch` bracketing needed.
This reuses the most-tested machinery and inherits its persistence,
metadata, and FK-validation behaviour for free.
A new typed command variant `Command::CreateM2nRelationship { t1, t2,
name }` carries the parsed form; the runtime/executor expands it to the
junction definition. (We do **not** lower it to `Command::CreateTable`
at parse time — keeping a distinct command preserves command identity
per the X5 "unique commands for every unique case" principle, and lets
the teaching echo speak in m:n terms.)
## Grammar, AST, and cross-cutting wiring
A new command is not done when it parses — it must light up every
surface a learner touches. Enumerated here so none is missed
(verification in **Testing**).
- **Grammar node** `CREATE_M2N` (`ddl.rs`): a separate `CommandNode`
with `entry: create`, shape `m:n relationship [as <name>] from <T1>
to <T2>`, registered in `REGISTRY` as `CommandCategory::Simple`. A
separate node (not a branch inside `CREATE`) keeps the tested
create-table builder untouched; the walker already dispatches
multiple nodes per entry word. The `m:n` opener mirrors `1:n`
(`Word("m")`, `Punct(':')`, `Word("n")`).
- **AST builder** `build_create_m2n``Command::CreateM2nRelationship`.
- **Command + worker plumbing:** `Command::CreateM2nRelationship`
variant; `Request::CreateM2nRelationship`; runtime
`execute_command_typed` arm; `Database::create_m2n_relationship`
public API; executor `do_create_m2n_relationship` (per D6).
- **Completion:** add `("m", "m:n")` to `COMPOSITE_CANDIDATES`
(`completion.rs`) so Tab on `create m` offers `m:n` as one fluent
piece (exactly as `("1", "1:n")` does for `add`). Identifier slots
for `<T1>`/`<T2>` inherit table-name completion from the walker's
`IdentSource::Tables` automatically.
- **Hints:** set `HintMode`s on the `CREATE_M2N` nodes so the ambient
hint panel guides `from` / table / `to` / table / optional `as`,
matching the `add 1:n relationship` hinting.
- **Highlighting:** automatic — `walker/highlight.rs` is grammar-driven
with no per-command special-casing; verify the line highlights.
- **Help:** `help_id: Some("ddl.create_m2n")` → the command appears in
`help` automatically (REGISTRY iteration) and under `help create`;
add the `help.ddl.create_m2n` catalog string.
- **Usage / parse errors:** `usage_ids: &["parse.usage.create_m2n"]`
→ a malformed `create m:n …` shows the form in the usage block; add
the `parse.usage.create_m2n` catalog string. Add the near-miss cases
to the `parse_error_pedagogy` matrix (ADR-0042) for the `create`
entry word.
- **Teaching echo** (`echo.rs` + `build_schema_echo`): a
`CreateM2nRelationship` arm that echoes the generated junction —
the `create table` it built plus the two `FOREIGN KEY` lines — so the
learner sees the pattern the convenience expanded to.
- **Structure render:** the executor returns the junction's
`TableDescription`; the ADR-0044 render path already draws its
relationships as diagrams on the create echo? No — incidental
`create table` echoes keep prose (ADR-0044 reach); the m:n echo shows
the junction structure with its outbound FKs in the standard prose
form. (A future enhancement could draw both relationships as
diagrams; out of scope here.)
## Genuine forks (escalated, all resolved 2026-06-10)
1. **Junction PK** — compound-over-FKs (chosen) vs surrogate serial +
UNIQUE vs no PK. → D1.
2. **Referential actions**`CASCADE` (chosen) vs `NO ACTION` vs
`RESTRICT`. → D2.
3. **Naming** — auto-name + optional `as` (chosen) vs auto-name only.
→ D3.
4. **Mode** — both (chosen by default, unobjected) vs simple-only. → D5.
## Testing
Integration (`tests/it/`), test-first:
- **Functional:** `create m:n relationship from A to B` creates table
`A_B` with the two FK columns, compound PK over them, and two
enforced FKs; inserting a junction row with a non-existent parent is
refused; the `(fk1, fk2)` pair is unique (duplicate link refused).
- **`as <name>`** overrides the junction name.
- **Compound parent PK:** a parent with a 2-column PK contributes two
FK columns; the junction PK spans all of them; FKs enforce per pair.
- **Cascade:** deleting a parent row removes its junction rows.
- **Undo:** one `create m:n` is exactly one undo step (table + both
relationships gone after `undo`).
- **Persistence round-trip:** the junction + both relationships survive
a save → rebuild-from-text.
- **Errors:** missing parent table; parent without a PK; junction-name
collision with an existing table; (self-m:n → OOS error, below).
Cross-cutting (the surfaces a new command must light up):
- **Completion:** Tab after `create ` surfaces `m:n relationship`;
table-name completion fires at the `<T1>`/`<T2>` slots.
- **Help:** `help` lists the command; `help create` includes the m:n
form; the catalog string renders.
- **Usage / parse pedagogy:** a bare/half `create m:n` shows the usage
block; near-miss matrix entries added (`parse_error_pedagogy`).
- **Hints + highlighting:** ambient hint progression through the form;
the line highlights (snapshot or assertion as the sibling commands
use).
All tiers green, zero skips; clippy clean (nursery).
## Out of scope
- **Self-referential m:n** (`from T to T`) — **refused outright**
(user-confirmed, "full stop"): the two FK column sets would collide
on `{T}_{pkcol}`, and directional disambiguation (`from_*`/`to_*`)
is more complexity than this beginner-facing convenience warrants.
The executor detects `t1 == t2` (on the canonical names) and errors
with a friendly pointer ("an m:n relationship needs two different
tables — to link a table to itself, add the junction by hand").
Not a deferred follow-up; a deliberate non-goal.
- **Per-relationship action overrides** in the command syntax (D2 fixes
`CASCADE`); use a hand-built junction for other actions.
- **Extra junction columns** (payload attributes on the link, e.g. an
enrolment date) — add them afterward with `add column`.
- **m:n visualization as diagrams** on the create echo (ADR-0044 reach
keeps incidental create echoes in prose).
- **Renaming the auto-generated *relationships*** (only the table is
`as`-nameable); drop + re-add covers it.