Files
rdbms-playground/docs/adr/0045-mn-convenience.md
T
claude@clouddev1 e598008ecf docs: ADR-0045 m:n convenience command (C4); accepted
create m:n relationship from <T1> to <T2> [as <name>] generates a
junction table (compound PK over the two FK column sets, CASCADE FKs)
plus two 1:n relationships, in one do_create_table call = one undo
step. Forks user-confirmed; /runda DA pass verified the reuse against
code and the no-PK-tables-exist-in-advanced-mode fact (parent-PK guard
retained). Self-referential m:n refused; FK cols named {table}_{pkcol}.
2026-06-10 13:18:07 +00:00

13 KiB

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<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 idStudents_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 ColumnSpecs (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_m2nCommand::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 HintModes 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 actionsCASCADE (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.