# ADR-0045: `create m:n relationship` convenience command (C4) ## Status Accepted (2026-06-10). Closes `requirements.md` **C4**. 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` 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 to ` 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 to [as ] ``` 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 `_`, optional `as ` (fork, user-chosen) The junction table is auto-named `{T1}_{T2}` (e.g. `Students_Courses`). An optional `as ` clause overrides it — consistent with `add 1:n relationship [as ]` 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`, 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 "`` 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 ] from to `, 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 ``/`` 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 `** 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 ``/`` 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.