From e598008ecf4f8d4a06a88bc2e531dc5a40526c97 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 13:18:07 +0000 Subject: [PATCH] docs: ADR-0045 m:n convenience command (C4); accepted create m:n relationship from to [as ] 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}. --- docs/adr/0045-mn-convenience.md | 269 ++++++++++++++++++++++++++++++++ docs/adr/README.md | 1 + 2 files changed, 270 insertions(+) create mode 100644 docs/adr/0045-mn-convenience.md diff --git a/docs/adr/0045-mn-convenience.md b/docs/adr/0045-mn-convenience.md new file mode 100644 index 0000000..971dd6e --- /dev/null +++ b/docs/adr/0045-mn-convenience.md @@ -0,0 +1,269 @@ +# 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. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1f623e3..abba83d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -50,3 +50,4 @@ This directory contains the project's ADRs, recorded per - [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

.(a, b) to .(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`) 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 ` (one full diagram), `show table ` (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) +- [ADR-0045 — `create m:n relationship` convenience command (C4)](0045-mn-convenience.md) — **Accepted 2026-06-10** (closes `requirements.md` **C4**; all forks user-confirmed + a `/runda` DA pass that verified the `do_create_table` reuse against code and corrected the "no PK-less tables" assumption — advanced SQL `create table t (a int)` has none, so a parent-PK guard is retained). `create m:n relationship from to [as ]` generates a junction table with one FK column per parent PK column, a **compound PK over all the FK columns** (the textbook junction — the pair is unique, no duplicate links), and **two 1:n relationships**, all in **one transaction = one undo step** (built by reusing `do_create_table`, which already takes `foreign_keys` + writes relationship metadata — no batch bracketing). Forks all user-chosen: junction PK = compound-over-FKs (vs surrogate serial / no PK); referential actions = **`CASCADE`** on delete+update (vs NO ACTION / RESTRICT); naming = auto `{T1}_{T2}` + optional `as` (vs auto-only); available in **both modes** (Simple-category DSL, like the sibling relationship commands). FK columns named `{parent_table}_{pk_column}` (disambiguates shared `id`; generalises to compound parents via ADR-0043), typed via `fk_target_type` (ADR-0011). A distinct `Command::CreateM2nRelationship` (not lowered to `CreateTable`) preserves command identity (X5) and lets the teaching echo speak in m:n terms. Cross-cutting wiring enumerated: separate `CREATE_M2N` `CommandNode` (own `help_id`/`usage_ids`), `("m","m:n")` completion composite, `HintMode`s, grammar-driven highlighting, `help`/`help create`, `parse_error_pedagogy` near-miss matrix, teaching echo. OOS: **self-referential m:n** (`from T to T`) refused outright (user-confirmed "full stop" — directional column-naming is more than this beginner convenience warrants); per-relationship action overrides; extra junction payload columns; m:n diagram echo; renaming the auto-generated relationships