`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.
15 KiB
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:
- Canonicalises
T1,T2(require_canonical_table) and reads each parent's PK (read_schema().primary_key). D7 — parent-PK guard: if either parent'sprimary_keyis 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 SQLcreate table t (a int)has no PK (sql_create_table.rsassertspk.is_empty()for that form) — not a theoretical one, so the guard is a correctness requirement, not defensive padding. - Builds the junction
ColumnSpecs (one per parent PK column, typed viafk_target_type), the compoundprimary_keylist, and twoSqlForeignKeyvalues (on_delete = on_update = Cascade). - Calls the create-table path, which creates the table + both FKs +
all metadata in one transaction — naturally one undo step, no
BeginBatch/EndBatchbracketing 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 separateCommandNodewithentry: create, shapem:n relationship [as <name>] from <T1> to <T2>, registered inREGISTRYasCommandCategory::Simple. A separate node (not a branch insideCREATE) keeps the tested create-table builder untouched; the walker already dispatches multiple nodes per entry word. Them:nopener mirrors1:n(Word("m"),Punct(':'),Word("n")). - AST builder
build_create_m2n→Command::CreateM2nRelationship. - Command + worker plumbing:
Command::CreateM2nRelationshipvariant;Request::CreateM2nRelationship; runtimeexecute_command_typedarm;Database::create_m2n_relationshippublic API; executordo_create_m2n_relationship(per D6). - Completion: add
("m", "m:n")toCOMPOSITE_CANDIDATES(completion.rs) so Tab oncreate moffersm:nas one fluent piece (exactly as("1", "1:n")does foradd). Identifier slots for<T1>/<T2>inherit table-name completion from the walker'sIdentSource::Tablesautomatically. - Hints: set
HintModes on theCREATE_M2Nnodes so the ambient hint panel guidesfrom/ table /to/ table / optionalas, matching theadd 1:n relationshiphinting. - Highlighting: automatic —
walker/highlight.rsis grammar-driven with no per-command special-casing; verify the line highlights. - Help:
help_id: Some("ddl.create_m2n")→ the command appears inhelpautomatically (REGISTRY iteration) and underhelp create; add thehelp.ddl.create_m2ncatalog string. - Usage / parse errors:
usage_ids: &["parse.usage.create_m2n"]→ a malformedcreate m:n …shows the form in the usage block; add theparse.usage.create_m2ncatalog string. Add the near-miss cases to theparse_error_pedagogymatrix (ADR-0042) for thecreateentry word. - Teaching echo (
echo.rs+build_schema_echo): aCreateM2nRelationshiparm that echoes the generated junction — thecreate tableit built plus the twoFOREIGN KEYlines — 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 — incidentalcreate tableechoes 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)
- Junction PK — compound-over-FKs (chosen) vs surrogate serial + UNIQUE vs no PK. → D1.
- Referential actions —
CASCADE(chosen) vsNO ACTIONvsRESTRICT. → D2. - Naming — auto-name + optional
as(chosen) vs auto-name only. → D3. - Mode — both (chosen by default, unobjected) vs simple-only. → D5.
Testing
Integration (tests/it/), test-first:
- Functional:
create m:n relationship from A to Bcreates tableA_Bwith 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:nis exactly one undo step (table + both relationships gone afterundo). - 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
createsurfacesm:n relationship; table-name completion fires at the<T1>/<T2>slots. - Help:
helplists the command;help createincludes the m:n form; the catalog string renders. - Usage / parse pedagogy: a bare/half
create m:nshows 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 detectst1 == 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.