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.
This commit is contained in:
claude@clouddev1
2026-06-10 14:26:33 +00:00
parent e598008ecf
commit 8bd43ccadf
28 changed files with 1273 additions and 26 deletions
+32 -1
View File
@@ -2,7 +2,38 @@
## Status ## Status
Accepted (2026-06-10). Closes `requirements.md` **C4**. All four 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 design forks were escalated and user-confirmed at the recommended
option (compound-over-FKs junction PK; `CASCADE` actions; auto-name + option (compound-over-FKs junction PK; `CASCADE` actions; auto-name +
optional `as`; both modes). Two follow-up points were also confirmed optional `as`; both modes). Two follow-up points were also confirmed
+1 -1
View File
@@ -50,4 +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-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 ~1520 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 <P>.(a, b) to <C>.(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<String>`) 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-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 ~1520 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 <P>.(a, b) to <C>.(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<String>`) 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 <name>` (one full diagram), `show table <T>` (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-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 <name>` (one full diagram), `show table <T>` (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 <T1> to <T2> [as <name>]` 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 - [ADR-0045 — `create m:n relationship` convenience command (C4)](0045-mn-convenience.md) — **Accepted + implemented 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). Implementation corrected a second ADR premise: "the walker already dispatches multiple nodes per entry word" held only in *advanced* mode — two simple-mode spots (dispatcher `decide`, completion continuation-merge) assumed ≤1 DSL form per entry word and were generalized **behaviour-preservingly** (dispatch reduces to the old single-candidate commit; completion merge gated on `simple_count > 1`). Junction echo wired (`render_create_m2n`, round-trips as SQL). `create m:n relationship from <T1> to <T2> [as <name>]` 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
+15 -1
View File
@@ -276,9 +276,23 @@ since ADR-0027.)
the same via drop + add today; one-step modify is a small the same via drop + add today; one-step modify is a small
follow-up using the existing rebuild-table machinery. ADR follow-up using the existing rebuild-table machinery. ADR
pending. pending.
- [ ] **C4** Convenience: `create m:n relationship from <T1> to - [x] **C4** Convenience: `create m:n relationship from <T1> to
<T2>` produces an auto-named junction table the user can rename; <T2>` produces an auto-named junction table the user can rename;
pulls primary keys and FK definitions automatically. pulls primary keys and FK definitions automatically.
*(Done 2026-06-10 via **ADR-0045**. `create m:n relationship from
<T1> to <T2> [as <name>]` builds 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`), available in both modes, compound-parent
PKs supported (ADR-0043). Self-referential m:n refused; PK-less parent
refused. Wired across every surface — completion (`m:n` composite),
hints, highlighting, `help`/usage, and the advanced-mode DSL→SQL
teaching echo (the generated `CREATE TABLE … FOREIGN KEY …`). 9
integration + 7 typing-surface + echo/parse unit tests. The build
surfaced — and fixed — two latent simple-mode dispatch/completion
assumptions ("≤1 DSL form per entry word"), now generalized
behaviour-preservingly.)*
- [x] **C5** Data operations: insert / update / delete via DSL. - [x] **C5** Data operations: insert / update / delete via DSL.
*(ADR-0014. INSERT short and long forms, UPDATE/DELETE with *(ADR-0014. INSERT short and long forms, UPDATE/DELETE with
required WHERE plus `--all-rows` opt-in, `show data <T>`, required WHERE plus `--all-rows` opt-in, `show data <T>`,
+18 -10
View File
@@ -2058,6 +2058,10 @@ impl App {
// column for a compound FK (ADR-0043). // column for a compound FK (ADR-0043).
parent_columns.first().map(String::as_str), parent_columns.first().map(String::as_str),
), ),
// m:n builds a junction table; its errors (missing parent,
// no PK, self-reference, name collision) name the relevant
// table in the message, so no fallback table/column here.
C::CreateM2nRelationship { .. } => (Operation::CreateTable, None, None),
C::DropRelationship { selector } => match selector { C::DropRelationship { selector } => match selector {
RelationshipSelector::Endpoints { RelationshipSelector::Endpoints {
parent_table, parent_table,
@@ -2927,13 +2931,15 @@ mod tests {
#[test] #[test]
fn tab_at_word_boundary_inserts_next_expected_keyword() { fn tab_at_word_boundary_inserts_next_expected_keyword() {
// `create ` → expects only `table`. Single candidate; // `change ` → expects only `column`. Single candidate;
// insert "table " with space, no memo. // insert "column " with space, no memo. (Uses `change`, not
// `create`: ADR-0045 made `create ` ambiguous — `table` vs
// `m:n` — so it is no longer a single-candidate boundary.)
let mut app = App::new(); let mut app = App::new();
type_str(&mut app, "create "); type_str(&mut app, "change ");
let actions = app.update(key(KeyCode::Tab)); let actions = app.update(key(KeyCode::Tab));
assert!(actions.is_empty()); assert!(actions.is_empty());
assert_eq!(app.input, "create table "); assert_eq!(app.input, "change column ");
assert!(app.last_completion.is_none()); assert!(app.last_completion.is_none());
} }
@@ -3080,17 +3086,19 @@ mod tests {
// Stage-8 follow-up #2 (testing-round-2): the // Stage-8 follow-up #2 (testing-round-2): the
// single-candidate-no-memo design lets the user chain // single-candidate-no-memo design lets the user chain
// Tabs through unique completions without getting // Tabs through unique completions without getting
// stuck. From "cr", Tab → "create ", Tab → "create // stuck. From "ch", Tab → "change ", Tab → "change
// table ". (Round 5 added the app-lifecycle commands — // column ". (Round 5 added the app-lifecycle commands —
// single-letter prefixes like `i` are now ambiguous // single-letter prefixes like `i` are now ambiguous
// (`insert` vs. `import`), so the test starts from a // (`insert` vs. `import`), so the test starts from a
// disambiguated two-letter prefix.) // disambiguated two-letter prefix. `change` is used rather
// than `create`: ADR-0045 made `create ` ambiguous (`table`
// vs `m:n`), so it no longer chains as a unique completion.)
let mut app = App::new(); let mut app = App::new();
type_str(&mut app, "cr"); type_str(&mut app, "ch");
app.update(key(KeyCode::Tab)); app.update(key(KeyCode::Tab));
assert_eq!(app.input, "create "); assert_eq!(app.input, "change ");
app.update(key(KeyCode::Tab)); app.update(key(KeyCode::Tab));
assert_eq!(app.input, "create table "); assert_eq!(app.input, "change column ");
assert!(app.last_completion.is_none()); assert!(app.last_completion.is_none());
} }
+11 -3
View File
@@ -31,6 +31,7 @@ use crate::mode::Mode;
/// fragments the user thinks of as a single phrase: /// fragments the user thinks of as a single phrase:
/// ///
/// - `1:n` — the opener for `add 1:n relationship`. /// - `1:n` — the opener for `add 1:n relationship`.
/// - `m:n` — the opener for `create m:n relationship` (ADR-0045).
/// - `double precision` — the lone two-word SQL type alias /// - `double precision` — the lone two-word SQL type alias
/// (ADR-0035 §6.3; the grammar has a dedicated branch so the per-word /// (ADR-0035 §6.3; the grammar has a dedicated branch so the per-word
/// `Ident` validator never has to make sense of `double` alone). /// `Ident` validator never has to make sense of `double` alone).
@@ -40,7 +41,7 @@ use crate::mode::Mode;
/// composite replaces the bare opener rather than appearing /// composite replaces the bare opener rather than appearing
/// alongside it. /// alongside it.
const COMPOSITE_CANDIDATES: &[(&str, &str)] = const COMPOSITE_CANDIDATES: &[(&str, &str)] =
&[("1", "1:n"), ("double", "double precision")]; &[("1", "1:n"), ("m", "m:n"), ("double", "double precision")];
/// Per-project schema lookup cache (ADR-0022 §9, ADR-0024 §Phase D). /// Per-project schema lookup cache (ADR-0022 §9, ADR-0024 §Phase D).
/// ///
@@ -1346,12 +1347,19 @@ mod tests {
fn at_token_boundary_offers_next_expected_keyword() { fn at_token_boundary_offers_next_expected_keyword() {
// After `create ` advanced mode offers `table` (valid in both // After `create ` advanced mode offers `table` (valid in both
// modes) plus the SQL-only `unique` (`create unique index`) and // modes) plus the SQL-only `unique` (`create unique index`) and
// `index` — the shared-entry-word merge (ADR-0035 §4i d). // `index` — the shared-entry-word merge (ADR-0035 §4i d) — and
// `m:n` (`create m:n relationship`, ADR-0045), surfaced as the
// composite (the bare `m` opener is filtered).
// `table` (Both) blocks before the Advanced-only `unique`/`index`. // `table` (Both) blocks before the Advanced-only `unique`/`index`.
let cs = cands("create ", 7); let cs = cands("create ", 7);
assert_eq!( assert_eq!(
cs, cs,
vec!["table".to_string(), "unique".to_string(), "index".to_string()] vec![
"table".to_string(),
"unique".to_string(),
"index".to_string(),
"m:n".to_string()
]
); );
} }
+171
View File
@@ -605,6 +605,13 @@ enum Request {
source: Option<String>, source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>, reply: oneshot::Sender<Result<TableDescription, DbError>>,
}, },
CreateM2nRelationship {
t1: String,
t2: String,
name: Option<String>,
source: Option<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
DropRelationship { DropRelationship {
selector: RelationshipSelector, selector: RelationshipSelector,
source: Option<String>, source: Option<String>,
@@ -1420,6 +1427,29 @@ impl Database {
recv.await.map_err(|_| DbError::WorkerGone)? recv.await.map_err(|_| DbError::WorkerGone)?
} }
/// Generate a junction table for an m:n relationship between
/// `t1` and `t2` (ADR-0045 / C4). One worker request = one undo
/// step (the junction + both relationships are built in a single
/// `do_create_table`).
pub async fn create_m2n_relationship(
&self,
t1: String,
t2: String,
name: Option<String>,
source: Option<String>,
) -> Result<TableDescription, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::CreateM2nRelationship {
t1,
t2,
name,
source,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn drop_relationship( pub async fn drop_relationship(
&self, &self,
selector: RelationshipSelector, selector: RelationshipSelector,
@@ -2347,6 +2377,24 @@ fn handle_request(
create_fk, create_fk,
)); ));
} }
Request::CreateM2nRelationship {
t1,
t2,
name,
source,
reply,
} => {
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
do_create_m2n_relationship(
conn,
persistence,
source.as_deref(),
&t1,
&t2,
name.as_deref(),
)
});
}
Request::DropRelationship { Request::DropRelationship {
selector, selector,
source, source,
@@ -3394,6 +3442,14 @@ fn do_create_table(
foreign_keys: &[SqlForeignKey], foreign_keys: &[SqlForeignKey],
) -> Result<TableDescription, DbError> { ) -> Result<TableDescription, DbError> {
debug!(table = %name, cols = columns.len(), pk = ?primary_key, "create_table"); debug!(table = %name, cols = columns.len(), pk = ?primary_key, "create_table");
// A new table may not take an internal `__rdbms_*` name (it would be
// filtered out of `list_tables` — a hidden orphan). The advanced-SQL
// create path rejects this at parse, but the simple-mode DSL
// `TABLE_NAME_NEW` slot has no validator, and `create m:n … as
// <name>` (ADR-0045) reaches here too — so the shared executor is the
// single place that closes every path (issue raised by the ADR-0045
// /runda pass).
reject_internal_table_name(name)?;
if columns.is_empty() { if columns.is_empty() {
// SQLite requires at least one column. The DSL grammar // SQLite requires at least one column. The DSL grammar
// already prevents this, but defending here too keeps // already prevents this, but defending here too keeps
@@ -7277,6 +7333,101 @@ fn resolve_create_table_fks(
Ok(out) Ok(out)
} }
/// Generate a junction table for an m:n relationship between `t1` and
/// `t2` (ADR-0045 / C4). Builds one FK column per parent PK column
/// (`{table}_{pkcol}`, typed via `fk_target_type` — ADR-0011), a
/// compound PK over all of them, and two `CASCADE` foreign keys, then
/// hands the whole thing to [`do_create_table`] — so the junction table
/// and both relationships are created in one transaction = one undo
/// step. Self-referential m:n is refused (column-name collision); a
/// PK-less parent is refused (nothing to reference).
fn do_create_m2n_relationship(
conn: &Connection,
persistence: Option<&Persistence>,
source: Option<&str>,
t1: &str,
t2: &str,
name: Option<&str>,
) -> Result<TableDescription, DbError> {
debug!(t1 = %t1, t2 = %t2, name = ?name, "create_m2n_relationship");
// Canonicalize both parents (refuse non-existent / internal tables).
let canon_t1 = require_canonical_table(conn, t1)?;
let t1 = canon_t1.as_str();
let canon_t2 = require_canonical_table(conn, t2)?;
let t2 = canon_t2.as_str();
// Self-referential m:n is OOS (ADR-0045): the two FK column sets
// would collide on `{T}_{pkcol}`, needing directional names this
// beginner convenience deliberately avoids.
if t1.eq_ignore_ascii_case(t2) {
return Err(DbError::Unsupported(format!(
"an m:n relationship needs two different tables (got `{t1}` twice). \
To link a table to itself, build the junction table by hand."
)));
}
let schema1 = read_schema(conn, t1)?;
let schema2 = read_schema(conn, t2)?;
// Build one FK column per parent PK column (compound parents
// contribute one each, ADR-0043) + the compound PK + the two FKs.
let mut columns: Vec<ColumnSpec> = Vec::new();
let mut primary_key: Vec<String> = Vec::new();
let mut foreign_keys: Vec<SqlForeignKey> = Vec::new();
for (tbl, schema) in [(t1, &schema1), (t2, &schema2)] {
// D7 parent-PK guard: advanced-mode SQL can create a PK-less
// table; it cannot anchor an m:n relationship.
if schema.primary_key.is_empty() {
return Err(DbError::Unsupported(format!(
"`{tbl}` has no primary key, so it cannot anchor an m:n relationship."
)));
}
let mut child_columns: Vec<String> = Vec::new();
for pkcol in &schema.primary_key {
let pcol = schema
.columns
.iter()
.find(|c| &c.name == pkcol)
.ok_or_else(|| DbError::Sqlite {
message: format!("no such column: {tbl}.{pkcol}"),
kind: SqliteErrorKind::NoSuchColumn,
})?;
let pty = pcol.user_type.ok_or_else(|| {
DbError::Unsupported("primary-key column has no user type metadata".to_string())
})?;
let col_name = format!("{tbl}_{pkcol}");
columns.push(ColumnSpec::new(col_name.clone(), pty.fk_target_type()));
primary_key.push(col_name.clone());
child_columns.push(col_name);
}
foreign_keys.push(SqlForeignKey {
name: None,
child_columns,
parent_table: tbl.to_string(),
parent_columns: Some(schema.primary_key.clone()),
on_delete: ReferentialAction::Cascade,
on_update: ReferentialAction::Cascade,
inline: false,
});
}
// Junction name: explicit `as <name>` or the auto-name `{t1}_{t2}`.
let junction = name.map_or_else(|| format!("{t1}_{t2}"), str::to_string);
debug!(junction = %junction, cols = columns.len(), "create_m2n_relationship: building junction table");
do_create_table(
conn,
persistence,
source,
&junction,
&columns,
&primary_key,
&[],
&[],
&foreign_keys,
)
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn do_add_relationship( fn do_add_relationship(
conn: &Connection, conn: &Connection,
@@ -10397,6 +10548,26 @@ mod tests {
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}"); assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
} }
#[tokio::test]
async fn create_table_rejects_an_internal_name() {
// A new table may not take an internal `__rdbms_*` name — it would
// be hidden from `list_tables`. The advanced-SQL path rejects this
// at parse; the shared executor guards every other path (the
// simple-mode DSL slot and `create m:n … as`, ADR-0045).
let db = db();
let err = db
.create_table(
"__rdbms_sneaky".to_string(),
vec![col("id", Type::Int)],
vec!["id".to_string()],
None,
)
.await
.unwrap_err();
assert!(matches!(err, DbError::Sqlite { kind: SqliteErrorKind::NoSuchTable, .. }), "got {err:?}");
assert!(db.list_tables().await.unwrap().is_empty());
}
#[tokio::test] #[tokio::test]
async fn drop_table_removes_it_from_list() { async fn drop_table_removes_it_from_list() {
let db = db(); let db = db();
+16
View File
@@ -277,6 +277,18 @@ pub enum Command {
on_update: ReferentialAction, on_update: ReferentialAction,
create_fk: bool, create_fk: bool,
}, },
/// Convenience: generate a junction table for a many-to-many
/// relationship between `t1` and `t2` (ADR-0045 / C4). The
/// executor builds a table with one FK column per parent PK
/// column (named `{table}_{pkcol}`, typed via `fk_target_type`),
/// a compound PK over all of them, and two `CASCADE` 1:n
/// relationships — all in one `create table` (one undo step).
/// `name` overrides the auto-generated junction name `{t1}_{t2}`.
CreateM2nRelationship {
t1: String,
t2: String,
name: Option<String>,
},
/// Drop a relationship by either user-given/auto-generated /// Drop a relationship by either user-given/auto-generated
/// name, or by positional reference to the FK endpoints. /// name, or by positional reference to the FK endpoints.
DropRelationship { DropRelationship {
@@ -915,6 +927,7 @@ impl Command {
Self::RenameColumn { .. } => "rename column", Self::RenameColumn { .. } => "rename column",
Self::ChangeColumnType { .. } => "change column", Self::ChangeColumnType { .. } => "change column",
Self::AddRelationship { .. } => "add relationship", Self::AddRelationship { .. } => "add relationship",
Self::CreateM2nRelationship { .. } => "create m:n relationship",
Self::DropRelationship { .. } => "drop relationship", Self::DropRelationship { .. } => "drop relationship",
Self::AddIndex { .. } => "add index", Self::AddIndex { .. } => "add index",
Self::DropIndex { .. } => "drop index", Self::DropIndex { .. } => "drop index",
@@ -991,6 +1004,9 @@ impl Command {
// table's "Referenced by" entry, which is what the // table's "Referenced by" entry, which is what the
// user looks at to confirm the relationship. // user looks at to confirm the relationship.
Self::AddRelationship { parent_table, .. } => parent_table, Self::AddRelationship { parent_table, .. } => parent_table,
// For m:n we focus on the first table; the executor builds
// and returns the junction's structure regardless.
Self::CreateM2nRelationship { t1, .. } => t1,
Self::DropRelationship { selector } => match selector { Self::DropRelationship { selector } => match selector {
RelationshipSelector::Endpoints { parent_table, .. } => parent_table, RelationshipSelector::Endpoints { parent_table, .. } => parent_table,
// For a named drop we don't know the parent table // For a named drop we don't know the parent table
+69
View File
@@ -1362,6 +1362,75 @@ pub static CREATE: CommandNode = CommandNode {
help_id: Some("ddl.create"), help_id: Some("ddl.create"),
usage_ids: &["parse.usage.create_table"],}; usage_ids: &["parse.usage.create_table"],};
// =================================================================
// create_m2n — `create m:n relationship from <T1> to <T2> [as <name>]`
// (ADR-0045 / C4). Generates an auto-named junction table with two FKs
// + two 1:n relationships. A *separate* `CommandNode` under the shared
// `create` entry word (the walker dispatches both); the `m` opener is a
// `Literal` (not a keyword) so it never shadows an identifier, mirroring
// the `1` in `add 1:n relationship`.
// =================================================================
const M2N_T1: Node = Node::Ident {
source: IdentSource::Tables,
role: "m2n_t1",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const M2N_T2: Node = Node::Ident {
source: IdentSource::Tables,
role: "m2n_t2",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
// Optional `as <junction name>` — a *new* table name (the junction),
// so it reuses `TABLE_NAME_NEW` (role `table_name`, `NewName` source +
// hint). The only `table_name` role in this path, so the builder reads
// it directly as the junction name.
const M2N_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), TABLE_NAME_NEW];
const M2N_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(M2N_AS_NAME_NODES));
const CREATE_M2N_NODES: &[Node] = &[
Node::Literal("m"),
Node::Punct(':'),
Node::Word(Word::keyword("n")),
Node::Word(Word::keyword("relationship")),
Node::Word(Word::keyword("from")),
M2N_T1,
Node::Word(Word::keyword("to")),
M2N_T2,
M2N_AS_NAME_OPT,
];
const CREATE_M2N_SHAPE: Node = Node::Seq(CREATE_M2N_NODES);
fn build_create_m2n(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::CreateM2nRelationship {
t1: require_ident(path, "m2n_t1")?,
t2: require_ident(path, "m2n_t2")?,
name: ident(path, "table_name").map(str::to_string),
})
}
pub static CREATE_M2N: CommandNode = CommandNode {
entry: Word::keyword("create"),
shape: CREATE_M2N_SHAPE,
ast_builder: build_create_m2n,
help_id: Some("ddl.create_m2n"),
usage_ids: &["parse.usage.create_m2n"],
};
/// The friendly error for a column type without a preceding name — /// The friendly error for a column type without a preceding name —
/// a structural impossibility given the grammar, defended anyway. /// a structural impossibility given the grammar, defended anyway.
fn sql_col_type_without_name() -> ValidationError { fn sql_col_type_without_name() -> ValidationError {
+14
View File
@@ -657,6 +657,12 @@ pub fn usage_key_for_input_in_mode(
if source.as_bytes().get(after).is_some_and(u8::is_ascii_digit) { if source.as_bytes().get(after).is_some_and(u8::is_ascii_digit) {
return keys.iter().copied().find(|k| k.ends_with("relationship")); return keys.iter().copied().find(|k| k.ends_with("relationship"));
} }
// The `create m:n relationship` form (ADR-0045) opens with `m:n`
// — a letter, so the digit branch misses it, and its usage key ends
// `…create_m2n` (not `relationship`).
if source[after..].get(..3).is_some_and(|s| s.eq_ignore_ascii_case("m:n")) {
return keys.iter().copied().find(|k| k.ends_with("m2n"));
}
// Otherwise the form word is an identifier — `column`, // Otherwise the form word is an identifier — `column`,
// `index`, `table`, `relationship` — matched against the // `index`, `table`, `relationship` — matched against the
// usage key's suffix. // usage key's suffix.
@@ -706,6 +712,7 @@ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
(&ddl::RENAME, CommandCategory::Simple), (&ddl::RENAME, CommandCategory::Simple),
(&ddl::CHANGE, CommandCategory::Simple), (&ddl::CHANGE, CommandCategory::Simple),
(&ddl::CREATE, CommandCategory::Simple), (&ddl::CREATE, CommandCategory::Simple),
(&ddl::CREATE_M2N, CommandCategory::Simple),
(&data::SHOW, CommandCategory::Simple), (&data::SHOW, CommandCategory::Simple),
(&data::INSERT, CommandCategory::Simple), (&data::INSERT, CommandCategory::Simple),
(&data::UPDATE, CommandCategory::Simple), (&data::UPDATE, CommandCategory::Simple),
@@ -852,6 +859,13 @@ mod usage_key_tests {
), ),
("show data T", "parse.usage.show_data"), ("show data T", "parse.usage.show_data"),
("show table T", "parse.usage.show_table"), ("show table T", "parse.usage.show_table"),
// `create` is multi-form (table vs m:n, ADR-0045): each typed
// form resolves to its own usage key.
("create table T with pk id(int)", "parse.usage.create_table"),
(
"create m:n relationship from A to B",
"parse.usage.create_m2n",
),
]; ];
for (input, expected) in cases { for (input, expected) in cases {
assert_eq!( assert_eq!(
+15
View File
@@ -211,6 +211,21 @@ mod tests {
assert_eq!(run("quit"), vec![(0, 4, HighlightClass::Keyword)]); assert_eq!(run("quit"), vec![(0, 4, HighlightClass::Keyword)]);
} }
#[test]
fn create_m2n_relationship_highlights_cleanly() {
// ADR-0045: a valid `create m:n relationship` line classifies
// with no Error runs; keywords are keywords and the table names
// are identifiers (the `m:n` opener is a Literal, keyword-classed).
let runs = run("create m:n relationship from A to B");
assert!(
!runs.iter().any(|(_, _, c)| *c == HighlightClass::Error),
"no Error highlight on a valid m:n line: {runs:?}"
);
let kinds: Vec<HighlightClass> = runs.iter().map(|(_, _, c)| *c).collect();
assert!(kinds.contains(&HighlightClass::Keyword), "keywords highlighted: {runs:?}");
assert!(kinds.contains(&HighlightClass::Identifier), "table names highlighted: {runs:?}");
}
#[test] #[test]
fn keyword_plus_identifier_via_walker() { fn keyword_plus_identifier_via_walker() {
// `show data Customers` walks end-to-end. // `show data Customers` walks end-to-end.
+63 -8
View File
@@ -406,13 +406,28 @@ pub fn completion_probe_in_mode(
// Mismatch and is naturally skipped — the viability check is the // Mismatch and is naturally skipped — the viability check is the
// gate, not the cursor depth. // gate, not the cursor depth.
let mut expected_modes = vec![crate::completion::ModeClass::Both; expected.len()]; let mut expected_modes = vec![crate::completion::ModeClass::Both; expected.len()];
if mode == crate::mode::Mode::Advanced { {
let s = skip_whitespace(source, 0); let s = skip_whitespace(source, 0);
if let Some((kw_start, kw_end)) = consume_ident(source, s) { if let Some((kw_start, kw_end)) = consume_ident(source, s) {
let entry = &source[kw_start..kw_end]; let entry = &source[kw_start..kw_end];
let candidates = grammar::commands_for_entry_word(entry); let candidates = grammar::commands_for_entry_word(entry);
if candidates.len() > 1 { use crate::dsl::grammar::CommandCategory;
use crate::dsl::grammar::CommandCategory; // Advanced mode merges DSL + SQL continuations across all
// candidate nodes; Simple mode merges only when an entry word
// has more than one DSL form (e.g. `create table` vs
// `create m:n relationship`, ADR-0045). With a single DSL form
// the committed node already carries every continuation, so
// that case is left untouched (its `Both` mode-class too) —
// keeping this zero-ripple for every existing command.
let simple_count = candidates
.iter()
.filter(|(_, _, c)| *c == CommandCategory::Simple)
.count();
let run_merge = match mode {
crate::mode::Mode::Advanced => candidates.len() > 1,
crate::mode::Mode::Simple => simple_count > 1,
};
if run_merge {
// (continuation word, produced-by-simple, produced-by-advanced) // (continuation word, produced-by-simple, produced-by-advanced)
let mut tally: Vec<(&'static str, bool, bool)> = Vec::new(); let mut tally: Vec<(&'static str, bool, bool)> = Vec::new();
// Continuations that aren't keyword/literal-shaped // Continuations that aren't keyword/literal-shaped
@@ -422,6 +437,13 @@ pub fn completion_probe_in_mode(
// for punctuation defaults to `Both`. // for punctuation defaults to `Both`.
let mut punct_tally: Vec<char> = Vec::new(); let mut punct_tally: Vec<char> = Vec::new();
for (_, node, category) in candidates { for (_, node, category) in candidates {
// Simple mode never offers advanced SQL continuations
// (ADR-0030 §2); only DSL forms contribute.
if mode == crate::mode::Mode::Simple
&& category == CommandCategory::Advanced
{
continue;
}
let mut sctx = context::WalkContext::with_schema(schema); let mut sctx = context::WalkContext::with_schema(schema);
sctx.mode = mode; sctx.mode = mode;
let (res, _) = let (res, _) =
@@ -2720,13 +2742,46 @@ fn decide(
// appended at the rendering layer (see // appended at the rendering layer (see
// `advanced_alternative_note`), combining the DSL fix with // `advanced_alternative_note`), combining the DSL fix with
// the mode hint. // the mode hint.
match simple.first() { if simple.is_empty() {
Some(&(sidx, snode)) => Decision::Commit { idx: sidx, node: snode }, let primary = candidates.first().map_or("", |(_, n, _)| n.entry.primary);
None => { return Decision::ThisIsSql { primary };
let primary = candidates.first().map_or("", |(_, n, _)| n.entry.primary); }
Decision::ThisIsSql { primary } // An entry word may register more than one DSL form
// (e.g. `create table` and `create m:n relationship`,
// ADR-0045). Commit the first that fully matches or is
// content-rejected (a `ValidationFailed` means the shape
// fits but the content is invalid — that error must
// surface), mirroring the advanced branch below. With a
// single DSL form this reduces to "commit it": a lone
// non-matching candidate falls through to the
// furthest-progress step and is committed anyway, so its
// positioned DSL error still surfaces (unchanged behaviour).
for &(idx, node) in &simple {
if matches!(
scratch_outcome(effective_source, kw_start, kw_end, node, mode, schema),
WalkOutcome::Match { .. } | WalkOutcome::ValidationFailed { .. }
) {
return Decision::Commit { idx, node };
} }
} }
// None matched — commit the furthest-progress candidate
// (first on ties) so the surfaced DSL error is the most
// informative.
let mut best = simple[0];
let mut best_progress =
scratch_progress(effective_source, kw_start, kw_end, best.1, mode, schema);
for &(idx, node) in &simple[1..] {
let progress =
scratch_progress(effective_source, kw_start, kw_end, node, mode, schema);
if progress > best_progress {
best = (idx, node);
best_progress = progress;
}
}
Decision::Commit {
idx: best.0,
node: best.1,
}
} }
crate::mode::Mode::Advanced => { crate::mode::Mode::Advanced => {
// Advanced candidates first, DSL as the fallback. // Advanced candidates first, DSL as the fallback.
+54
View File
@@ -15,6 +15,7 @@
use crate::app::EffectiveMode; use crate::app::EffectiveMode;
use crate::dsl::ReferentialAction; use crate::dsl::ReferentialAction;
use crate::dsl::types::Type;
use crate::dsl::Command; use crate::dsl::Command;
use crate::dsl::command::{ use crate::dsl::command::{
ColumnSpec, CompareOp, Constraint, ConstraintKind, Expr, Operand, Predicate, RowFilter, ColumnSpec, CompareOp, Constraint, ConstraintKind, Expr, Operand, Predicate, RowFilter,
@@ -286,6 +287,31 @@ pub(crate) fn render_add_relationship(
s s
} }
/// The advanced-mode DSL→SQL teaching echo (ADR-0038) for `create m:n
/// relationship` (ADR-0045): the single `CREATE TABLE` the junction
/// expands to — every FK column, the compound primary key over them,
/// and the two `CASCADE` foreign keys (m:n always cascades, D2). Built
/// from the post-exec junction description (the resolved columns don't
/// exist on the command), so it shows exactly what was created.
pub(crate) fn render_create_m2n(
junction: &str,
columns: &[(String, Type)],
primary_key: &[String],
foreign_keys: &[(Vec<String>, String, Vec<String>)],
) -> String {
let mut parts: Vec<String> =
columns.iter().map(|(n, ty)| format!("{n} {}", ty.keyword())).collect();
parts.push(format!("PRIMARY KEY ({})", primary_key.join(", ")));
for (child_columns, parent_table, parent_columns) in foreign_keys {
parts.push(format!(
"FOREIGN KEY ({}) REFERENCES {parent_table} ({}) ON DELETE CASCADE ON UPDATE CASCADE",
child_columns.join(", "),
parent_columns.join(", "),
));
}
format!("CREATE TABLE {junction} ({})", parts.join(", "))
}
/// `ALTER TABLE <C> DROP CONSTRAINT <name>` — the `drop relationship` /// `ALTER TABLE <C> DROP CONSTRAINT <name>` — the `drop relationship`
/// echo (ADR-0038 §7 Bucket B). The runtime resolves both `name` (for an /// echo (ADR-0038 §7 Bucket B). The runtime resolves both `name` (for an
/// `Endpoints` selector) and `child_table` (for a `Named` selector) **pre- /// `Endpoints` selector) and `child_table` (for a `Named` selector) **pre-
@@ -1077,6 +1103,34 @@ mod tests {
); );
} }
#[test]
fn create_m2n_echo_renders_junction_and_round_trips() {
// The advanced-mode teaching echo for `create m:n relationship`
// (ADR-0045): the single CREATE TABLE the junction expands to,
// compound PK + the two CASCADE FKs — and it is valid SQL.
let sql = render_create_m2n(
"Students_Courses",
&[
("Students_id".to_string(), Type::Int),
("Courses_id".to_string(), Type::Int),
],
&["Students_id".to_string(), "Courses_id".to_string()],
&[
(vec!["Students_id".to_string()], "Students".to_string(), vec!["id".to_string()]),
(vec!["Courses_id".to_string()], "Courses".to_string(), vec!["id".to_string()]),
],
);
assert_eq!(
sql,
"CREATE TABLE Students_Courses (Students_id int, Courses_id int, \
PRIMARY KEY (Students_id, Courses_id), \
FOREIGN KEY (Students_id) REFERENCES Students (id) ON DELETE CASCADE ON UPDATE CASCADE, \
FOREIGN KEY (Courses_id) REFERENCES Courses (id) ON DELETE CASCADE ON UPDATE CASCADE)"
);
// The echoed SQL is valid advanced-mode SQL (round-trips).
assert!(matches!(reparse(&sql), Ok(Command::SqlCreateTable { .. })));
}
// --- expr / literal rendering ------------------------------------ // --- expr / literal rendering ------------------------------------
#[test] #[test]
+2
View File
@@ -190,6 +190,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("help.app.redo", &[]), ("help.app.redo", &[]),
("help.app.copy", &[]), ("help.app.copy", &[]),
("help.ddl.create", &[]), ("help.ddl.create", &[]),
("help.ddl.create_m2n", &[]),
("help.ddl.sql_create_table", &[]), ("help.ddl.sql_create_table", &[]),
("help.ddl.sql_drop_table", &[]), ("help.ddl.sql_drop_table", &[]),
("help.ddl.sql_create_index", &[]), ("help.ddl.sql_create_index", &[]),
@@ -277,6 +278,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("parse.usage.add_relationship", &[]), ("parse.usage.add_relationship", &[]),
("parse.usage.change_column", &[]), ("parse.usage.change_column", &[]),
("parse.usage.create_table", &[]), ("parse.usage.create_table", &[]),
("parse.usage.create_m2n", &[]),
("parse.usage.sql_create_table", &[]), ("parse.usage.sql_create_table", &[]),
("parse.usage.sql_drop_table", &[]), ("parse.usage.sql_drop_table", &[]),
("parse.usage.sql_create_index", &[]), ("parse.usage.sql_create_index", &[]),
+4
View File
@@ -279,6 +279,9 @@ help:
ddl: ddl:
create: |- create: |-
create table <T> with pk [<col>(<type>), ...] — create a table create table <T> with pk [<col>(<type>), ...] — create a table
create_m2n: |-
create m:n relationship from <T1> to <T2> [as <name>]
— build a junction table linking two tables
sql_create_table: |- sql_create_table: |-
create table [if not exists] <T> ( create table [if not exists] <T> (
<col> <type> [not null] [unique] [primary key] [default <expr>] [check (<expr>)] [references <P>[(<col>)]], ... <col> <type> [not null] [unique] [primary key] [default <expr>] [check (<expr>)] [references <P>[(<col>)]], ...
@@ -523,6 +526,7 @@ parse:
# placeholders. ADR-0009's surface conventions apply. # placeholders. ADR-0009's surface conventions apply.
usage: usage:
create_table: "create table <Name> with pk [<col>(<type>)[, ...]]" create_table: "create table <Name> with pk [<col>(<type>)[, ...]]"
create_m2n: "create m:n relationship from <Table1> to <Table2> [as <Name>]"
# Terse one-line synopsis (issue #12): the full grammar — every # Terse one-line synopsis (issue #12): the full grammar — every
# column- and table-level constraint — lives in `help.ddl.sql_create_table`. # column- and table-level constraint — lives in `help.ddl.sql_create_table`.
sql_create_table: "create table [if not exists] <Name> (<col> <type> [constraints], ...)" sql_create_table: "create table [if not exists] <Name> (<col> <type> [constraints], ...)"
+22
View File
@@ -1832,6 +1832,24 @@ fn build_schema_echo(
.map(|(name, child_table)| { .map(|(name, child_table)| {
vec![crate::echo::render_drop_relationship(name, child_table)] vec![crate::echo::render_drop_relationship(name, child_table)]
}), }),
// `create m:n relationship` (ADR-0045): the resolved junction
// columns/FKs only exist on the post-exec description, so the
// teaching echo is rendered from it (not `command_to_sql`).
Command::CreateM2nRelationship { .. } => description.map(|desc| {
let columns: Vec<(String, crate::dsl::types::Type)> = desc
.columns
.iter()
.filter_map(|c| c.user_type.map(|ty| (c.name.clone(), ty)))
.collect();
let primary_key: Vec<String> =
desc.columns.iter().filter(|c| c.primary_key).map(|c| c.name.clone()).collect();
let foreign_keys: Vec<(Vec<String>, String, Vec<String>)> = desc
.outbound_relationships
.iter()
.map(|r| (r.local_columns.clone(), r.other_table.clone(), r.other_columns.clone()))
.collect();
vec![crate::echo::render_create_m2n(&desc.name, &columns, &primary_key, &foreign_keys)]
}),
// Everything else (Bucket A pure-Command, plus the no-echo Bucket C // Everything else (Bucket A pure-Command, plus the no-echo Bucket C
// variants like `Sql*` / `ShowTable`) routes through the existing // variants like `Sql*` / `ShowTable`) routes through the existing
// `echo::command_to_sql` — wrapping its `Option<String>` to fit the // `echo::command_to_sql` — wrapping its `Option<String>` to fit the
@@ -2657,6 +2675,10 @@ async fn execute_command_typed(
) )
.await .await
.map(|d| CommandOutcome::Schema(Some(d))), .map(|d| CommandOutcome::Schema(Some(d))),
Command::CreateM2nRelationship { t1, t2, name } => database
.create_m2n_relationship(t1, t2, name, src)
.await
.map(|d| CommandOutcome::Schema(Some(d))),
Command::DropRelationship { selector } => database Command::DropRelationship { selector } => database
.drop_relationship(selector, src) .drop_relationship(selector, src)
.await .await
+418
View File
@@ -0,0 +1,418 @@
//! Integration tests for the m:n convenience command (C4 / ADR-0045):
//! `create m:n relationship from <T1> to <T2> [as <name>]`.
//!
//! Covers parse, junction generation (columns / compound PK / two
//! enforced FKs), the `as <name>` override, a compound-PK parent,
//! CASCADE delete, one-undo-step, self-m:n refusal, and the PK-less
//! parent guard.
use rdbms_playground::db::Database;
use rdbms_playground::dsl::command::RowFilter;
use rdbms_playground::dsl::{parse_command, ColumnSpec, Command, Type, Value};
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project::{self, PLAYGROUND_DB};
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio rt")
}
fn open() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let project = project::open_or_create(None, Some(dir.path())).expect("project");
let db = Database::open_with_persistence(project.db_path(), Persistence::new(project.path().to_path_buf()))
.expect("db");
(project, db, dir)
}
fn open_with_undo() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let project = project::open_or_create(None, Some(dir.path())).expect("project");
let db = Database::open_with_persistence_and_undo(
project.db_path(),
Persistence::new(project.path().to_path_buf()),
true,
)
.expect("db");
(project, db, dir)
}
/// A parent table `(id serial PK, label text)` — the `label` gives an
/// insertable non-PK column (a serial-PK-only table has nothing to put
/// in a short-form INSERT).
async fn serial_pk_table(db: &Database, name: &str) {
db.create_table(
name.to_string(),
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("label", Type::Text)],
vec!["id".to_string()],
None,
)
.await
.unwrap_or_else(|e| panic!("create {name}: {e}"));
}
/// Insert one row into a `serial_pk_table`, returning its auto-assigned id.
async fn add_row(db: &Database, table: &str, label: &str) {
db.insert(
table.to_string(),
Some(vec!["label".to_string()]),
vec![Value::Text(label.to_string())],
None,
)
.await
.unwrap_or_else(|e| panic!("insert into {table}: {e}"));
}
// ---- parse layer -----------------------------------------------
#[test]
fn parses_to_create_m2n_relationship() {
match parse_command("create m:n relationship from Students to Courses").expect("parses") {
Command::CreateM2nRelationship { t1, t2, name } => {
assert_eq!(t1, "Students");
assert_eq!(t2, "Courses");
assert_eq!(name, None);
}
other => panic!("expected CreateM2nRelationship, got {other:?}"),
}
}
#[test]
fn parses_with_as_name() {
match parse_command("create m:n relationship from Students to Courses as Enrollments")
.expect("parses")
{
Command::CreateM2nRelationship { name, .. } => assert_eq!(name.as_deref(), Some("Enrollments")),
other => panic!("expected CreateM2nRelationship, got {other:?}"),
}
}
// ---- junction generation ---------------------------------------
#[test]
fn generates_junction_with_compound_pk_and_two_enforced_fks() {
let (_p, db, _d) = open();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
serial_pk_table(&db, "Courses").await;
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
.await
.expect("create m:n");
// Auto-named `Students_Courses` exists.
let tables = db.list_tables().await.unwrap();
assert!(tables.contains(&"Students_Courses".to_string()), "tables: {tables:?}");
// Two FK columns, both part of the compound PK.
let desc = db.describe_table("Students_Courses".to_string(), None).await.unwrap();
let cols: Vec<(&str, bool)> =
desc.columns.iter().map(|c| (c.name.as_str(), c.primary_key)).collect();
assert_eq!(
cols,
vec![("Students_id", true), ("Courses_id", true)],
"expected two FK columns forming the compound PK"
);
// Two outbound relationships (one per parent).
assert_eq!(desc.outbound_relationships.len(), 2, "expected two FKs");
// FK enforcement: a junction row needs existing parents.
add_row(&db, "Students", "s1").await;
add_row(&db, "Courses", "c1").await;
db.insert(
"Students_Courses".to_string(),
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
None,
)
.await
.expect("valid link");
// Duplicate link refused by the compound PK.
let dup = db
.insert(
"Students_Courses".to_string(),
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
None,
)
.await;
assert!(dup.is_err(), "duplicate (Students_id, Courses_id) must be refused");
// A link to a non-existent parent is refused by the FK.
let orphan = db
.insert(
"Students_Courses".to_string(),
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("99".to_string())],
None,
)
.await;
assert!(orphan.is_err(), "link to a non-existent Course must be refused");
});
}
#[test]
fn as_name_overrides_the_junction_table_name() {
let (_p, db, _d) = open();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
serial_pk_table(&db, "Courses").await;
db.create_m2n_relationship(
"Students".to_string(),
"Courses".to_string(),
Some("Enrollments".to_string()),
None,
)
.await
.expect("create m:n as Enrollments");
let tables = db.list_tables().await.unwrap();
assert!(tables.contains(&"Enrollments".to_string()), "tables: {tables:?}");
assert!(!tables.contains(&"Students_Courses".to_string()));
});
}
#[test]
fn compound_parent_pk_contributes_one_fk_column_each() {
let (_p, db, _d) = open();
rt().block_on(async {
// Sections has a 2-column PK (course_id, term).
db.create_table(
"Sections".to_string(),
vec![ColumnSpec::new("course_id", Type::Int), ColumnSpec::new("term", Type::Int)],
vec!["course_id".to_string(), "term".to_string()],
None,
)
.await
.unwrap();
serial_pk_table(&db, "Students").await;
db.create_m2n_relationship("Students".to_string(), "Sections".to_string(), None, None)
.await
.expect("create m:n");
let desc = db.describe_table("Students_Sections".to_string(), None).await.unwrap();
let names: Vec<&str> = desc.columns.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["Students_id", "Sections_course_id", "Sections_term"]);
// All three form the compound PK.
assert!(desc.columns.iter().all(|c| c.primary_key), "all columns are PK: {names:?}");
});
}
#[test]
fn deleting_a_parent_cascades_to_the_junction() {
let (_p, db, _d) = open();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
serial_pk_table(&db, "Courses").await;
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
.await
.unwrap();
add_row(&db, "Students", "s1").await;
add_row(&db, "Courses", "c1").await;
db.insert(
"Students_Courses".to_string(),
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
None,
)
.await
.unwrap();
// Deleting the student cascades to the junction (ON DELETE CASCADE).
db.delete("Students".to_string(), RowFilter::AllRows, None).await.unwrap();
let rows = db.query_data("Students_Courses".to_string(), None, None, None).await.unwrap();
assert!(rows.rows.is_empty(), "junction rows should cascade-delete, got {:?}", rows.rows);
});
}
#[test]
fn create_m2n_is_one_undo_step() {
let (_p, db, _d) = open_with_undo();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
serial_pk_table(&db, "Courses").await;
// A real source makes the command undoable (a source-less call is
// treated as an internal, non-undoable op).
db.create_m2n_relationship(
"Students".to_string(),
"Courses".to_string(),
None,
Some("create m:n relationship from Students to Courses".to_string()),
)
.await
.unwrap();
assert!(db.list_tables().await.unwrap().contains(&"Students_Courses".to_string()));
// One undo removes the junction table AND both relationships.
db.undo().await.unwrap();
let tables = db.list_tables().await.unwrap();
assert!(!tables.contains(&"Students_Courses".to_string()), "undo should remove the junction: {tables:?}");
// The parents' relationships are gone too (the junction held them).
let students = db.describe_table("Students".to_string(), None).await.unwrap();
assert!(students.inbound_relationships.is_empty(), "no leftover relationship after undo");
});
}
// ---- guards ----------------------------------------------------
#[test]
fn self_referential_m2n_is_refused() {
let (_p, db, _d) = open();
rt().block_on(async {
serial_pk_table(&db, "Users").await;
let err = db
.create_m2n_relationship("Users".to_string(), "Users".to_string(), None, None)
.await
.expect_err("self m:n must be refused");
assert!(format!("{err}").contains("two different tables"), "got: {err}");
});
}
#[test]
fn missing_parent_table_is_refused() {
let (_p, db, _d) = open();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
let err = db
.create_m2n_relationship("Students".to_string(), "Nonexistent".to_string(), None, None)
.await
.expect_err("a missing parent table must be refused");
// The standard "no such table" guard (require_canonical_table).
assert!(format!("{err}").to_lowercase().contains("no such table"), "got: {err}");
});
}
#[test]
fn junction_name_collision_is_refused() {
let (_p, db, _d) = open();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
serial_pk_table(&db, "Courses").await;
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
.await
.expect("first m:n");
// A second identical m:n collides on the auto-name `Students_Courses`.
let err = db
.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
.await
.expect_err("a junction-name collision must be refused");
assert!(format!("{err}").to_lowercase().contains("exist"), "got: {err}");
});
}
// ---- the junction is a normal table ----------------------------
#[test]
fn the_junction_can_be_renamed() {
// C4 requirement text: "an auto-named junction table the user can
// rename." It is a normal table, so `rename table` works.
let (_p, db, _d) = open();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
serial_pk_table(&db, "Courses").await;
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
.await
.unwrap();
db.rename_table("Students_Courses".to_string(), "Enrollments".to_string(), None)
.await
.expect("rename the junction");
let tables = db.list_tables().await.unwrap();
assert!(tables.contains(&"Enrollments".to_string()), "tables: {tables:?}");
assert!(!tables.contains(&"Students_Courses".to_string()));
// Both relationships survive the rename (rebuild-preserving).
let desc = db.describe_table("Enrollments".to_string(), None).await.unwrap();
assert_eq!(desc.outbound_relationships.len(), 2, "FKs preserved across rename");
});
}
#[test]
fn junction_survives_save_and_rebuild() {
// Persistence round-trip: the junction + both relationships are
// reconstructed from project.yaml after the .db is discarded.
let dir = tempfile::tempdir().expect("tempdir");
let project_path = {
let project = project::open_or_create(None, Some(dir.path())).unwrap();
let path = project.path().to_path_buf();
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
.unwrap();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
serial_pk_table(&db, "Courses").await;
db.create_m2n_relationship(
"Students".to_string(),
"Courses".to_string(),
None,
Some("create m:n relationship from Students to Courses".to_string()),
)
.await
.unwrap();
});
drop(db);
drop(project);
path
};
// Discard the derived .db so the next open rebuilds from text.
std::fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap();
let project = project::Project::open(&project_path).unwrap();
let db =
Database::open_with_persistence(project.db_path(), Persistence::new(project.path().to_path_buf()))
.unwrap();
rt().block_on(async {
db.rebuild_from_text(project.path().to_path_buf(), None).await.expect("rebuild");
let tables = db.list_tables().await.unwrap();
assert!(tables.contains(&"Students_Courses".to_string()), "junction survived: {tables:?}");
let desc = db.describe_table("Students_Courses".to_string(), None).await.unwrap();
assert_eq!(desc.outbound_relationships.len(), 2, "both FKs reconstructed");
assert!(desc.columns.iter().all(|c| c.primary_key), "compound PK reconstructed");
});
}
#[test]
fn as_an_internal_name_is_refused() {
// The junction must be a real, listable table — an `as __rdbms_*`
// name would be filtered out of `list_tables` (a hidden orphan).
// Guarded in the shared `do_create_table` (ADR-0045 /runda finding).
let (_p, db, _d) = open();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
serial_pk_table(&db, "Courses").await;
let err = db
.create_m2n_relationship(
"Students".to_string(),
"Courses".to_string(),
Some("__rdbms_evil".to_string()),
None,
)
.await
.expect_err("an internal junction name must be refused");
assert!(format!("{err}").contains("no such table"), "got: {err}");
assert!(!db.list_tables().await.unwrap().contains(&"__rdbms_evil".to_string()));
});
}
#[test]
fn pk_less_parent_is_refused() {
let (_p, db, _d) = open();
rt().block_on(async {
serial_pk_table(&db, "Students").await;
// A PK-less table via the advanced SQL path.
db.sql_create_table(
"Loose".to_string(),
vec![ColumnSpec::new("a", Type::Int)],
vec![],
vec![],
vec![],
vec![],
false,
None,
)
.await
.unwrap();
let err = db
.create_m2n_relationship("Students".to_string(), "Loose".to_string(), None, None)
.await
.expect_err("a PK-less parent must be refused");
assert!(format!("{err}").contains("no primary key"), "got: {err}");
});
}
+1
View File
@@ -19,6 +19,7 @@ mod iteration4a_rebuild_command;
mod iteration4b_lifecycle_commands; mod iteration4b_lifecycle_commands;
mod iteration5_export_import; mod iteration5_export_import;
mod iteration6_resume_history; mod iteration6_resume_history;
mod m2n;
mod parse_error_pedagogy; mod parse_error_pedagogy;
mod project_lifecycle; mod project_lifecycle;
mod replay_command; mod replay_command;
+73
View File
@@ -0,0 +1,73 @@
//! Matrix coverage for `create m:n relationship from <T1> to <T2>
//! [as <name>]` (C4 / ADR-0045). Exercises the full typing surface —
//! completion candidates, ambient hint, highlighting, and parse state —
//! at each stage, so a regression in any of those surfaces is caught.
use crate::typing_surface::*;
use rdbms_playground::input_render::InputState;
#[test]
fn after_create_offers_table_and_m2n() {
let schema = schema_multi_table();
let a = assess_at_end("create ", &schema);
assert!(matches!(a.state, InputState::IncompleteAtEof));
// `create` branches to `table` (create table) or the `m:n` composite.
assert_candidate_present(&a, &["table", "m:n"]);
crate::snap!("after_create", a);
}
#[test]
fn m2n_relationship_keyword_sequence_is_incomplete() {
let schema = schema_multi_table();
let a = assess_at_end("create m:n relationship ", &schema);
assert!(matches!(a.state, InputState::IncompleteAtEof));
assert_candidate_present(&a, &["from"]);
crate::snap!("after_relationship_keyword", a);
}
#[test]
fn after_from_offers_table_names() {
let schema = schema_multi_table();
let a = assess_at_end("create m:n relationship from ", &schema);
assert!(matches!(a.state, InputState::IncompleteAtEof));
assert_candidate_present(&a, &["Customers", "Orders"]);
crate::snap!("after_from", a);
}
#[test]
fn after_to_offers_table_names() {
let schema = schema_multi_table();
let a = assess_at_end("create m:n relationship from Customers to ", &schema);
assert!(matches!(a.state, InputState::IncompleteAtEof));
assert_candidate_present(&a, &["Customers", "Orders"]);
crate::snap!("after_to", a);
}
#[test]
fn complete_create_m2n_parses() {
let schema = schema_multi_table();
let a = assess_at_end("create m:n relationship from Customers to Orders", &schema);
assert!(matches!(a.state, InputState::Valid));
assert_eq!(a.parse_result.as_deref(), Ok("CreateM2nRelationship"));
crate::snap!("complete", a);
}
#[test]
fn create_m2n_with_as_name_parses() {
let schema = schema_multi_table();
let a = assess_at_end(
"create m:n relationship from Customers to Orders as CustomerOrders",
&schema,
);
assert!(matches!(a.state, InputState::Valid));
assert_eq!(a.parse_result.as_deref(), Ok("CreateM2nRelationship"));
crate::snap!("with_as_name", a);
}
#[test]
fn after_as_keyword_is_incomplete() {
let schema = schema_multi_table();
let a = assess_at_end("create m:n relationship from Customers to Orders as ", &schema);
assert!(matches!(a.state, InputState::IncompleteAtEof));
crate::snap!("after_as", a);
}
+2
View File
@@ -35,6 +35,7 @@ pub mod create_table;
pub mod drop_column; pub mod drop_column;
pub mod drop_relationship; pub mod drop_relationship;
pub mod add_relationship; pub mod add_relationship;
pub mod create_m2n;
pub mod index_ops; pub mod index_ops;
pub mod constraints; pub mod constraints;
pub mod rename_change_column; pub mod rename_change_column;
@@ -224,6 +225,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String {
RenameColumn { .. } => "RenameColumn".into(), RenameColumn { .. } => "RenameColumn".into(),
ChangeColumnType { .. } => "ChangeColumnType".into(), ChangeColumnType { .. } => "ChangeColumnType".into(),
AddRelationship { .. } => "AddRelationship".into(), AddRelationship { .. } => "AddRelationship".into(),
CreateM2nRelationship { .. } => "CreateM2nRelationship".into(),
DropRelationship { .. } => "DropRelationship".into(), DropRelationship { .. } => "DropRelationship".into(),
AddIndex { .. } => "AddIndex".into(), AddIndex { .. } => "AddIndex".into(),
DropIndex { .. } => "DropIndex".into(), DropIndex { .. } => "DropIndex".into(),
@@ -0,0 +1,20 @@
---
source: tests/typing_surface/create_m2n.rs
assertion_line: 72
description: "input=\"create m:n relationship from Customers to Orders as \" cursor=52"
expression: "& a"
---
Assessment {
input: "create m:n relationship from Customers to Orders as ",
cursor: 52,
state: IncompleteAtEof,
hint: Some(
Prose(
"Type a name",
),
),
completion: None,
parse_result: Err(
"Invalid(at_eof)",
),
}
@@ -0,0 +1,52 @@
---
source: tests/typing_surface/create_m2n.rs
assertion_line: 16
description: "input=\"create \" cursor=7"
expression: "& a"
---
Assessment {
input: "create ",
cursor: 7,
state: IncompleteAtEof,
hint: Some(
Candidates {
items: [
Candidate {
text: "table",
kind: Keyword,
mode: Simple,
},
Candidate {
text: "m:n",
kind: Keyword,
mode: Both,
},
],
selected: None,
},
),
completion: Some(
Completion {
replaced_range: (
7,
7,
),
partial_prefix: "",
candidates: [
Candidate {
text: "table",
kind: Keyword,
mode: Simple,
},
Candidate {
text: "m:n",
kind: Keyword,
mode: Both,
},
],
},
),
parse_result: Err(
"Invalid(at_eof)",
),
}
@@ -0,0 +1,52 @@
---
source: tests/typing_surface/create_m2n.rs
assertion_line: 34
description: "input=\"create m:n relationship from \" cursor=29"
expression: "& a"
---
Assessment {
input: "create m:n relationship from ",
cursor: 29,
state: IncompleteAtEof,
hint: Some(
Candidates {
items: [
Candidate {
text: "Customers",
kind: Identifier,
mode: Both,
},
Candidate {
text: "Orders",
kind: Identifier,
mode: Both,
},
],
selected: None,
},
),
completion: Some(
Completion {
replaced_range: (
29,
29,
),
partial_prefix: "",
candidates: [
Candidate {
text: "Customers",
kind: Identifier,
mode: Both,
},
Candidate {
text: "Orders",
kind: Identifier,
mode: Both,
},
],
},
),
parse_result: Err(
"Invalid(at_eof)",
),
}
@@ -0,0 +1,52 @@
---
source: tests/typing_surface/create_m2n.rs
assertion_line: 43
description: "input=\"create m:n relationship from Customers to \" cursor=42"
expression: "& a"
---
Assessment {
input: "create m:n relationship from Customers to ",
cursor: 42,
state: IncompleteAtEof,
hint: Some(
Candidates {
items: [
Candidate {
text: "Customers",
kind: Identifier,
mode: Both,
},
Candidate {
text: "Orders",
kind: Identifier,
mode: Both,
},
],
selected: None,
},
),
completion: Some(
Completion {
replaced_range: (
42,
42,
),
partial_prefix: "",
candidates: [
Candidate {
text: "Customers",
kind: Identifier,
mode: Both,
},
Candidate {
text: "Orders",
kind: Identifier,
mode: Both,
},
],
},
),
parse_result: Err(
"Invalid(at_eof)",
),
}
@@ -0,0 +1,20 @@
---
source: tests/typing_surface/create_m2n.rs
assertion_line: 52
description: "input=\"create m:n relationship from Customers to Orders\" cursor=48"
expression: "& a"
---
Assessment {
input: "create m:n relationship from Customers to Orders",
cursor: 48,
state: Valid,
hint: Some(
Prose(
"Submit with Enter",
),
),
completion: None,
parse_result: Ok(
"CreateM2nRelationship",
),
}
@@ -0,0 +1,20 @@
---
source: tests/typing_surface/create_m2n.rs
assertion_line: 64
description: "input=\"create m:n relationship from Customers to Orders as CustomerOrders\" cursor=66"
expression: "& a"
---
Assessment {
input: "create m:n relationship from Customers to Orders as CustomerOrders",
cursor: 66,
state: Valid,
hint: Some(
Prose(
"Type a name",
),
),
completion: None,
parse_result: Ok(
"CreateM2nRelationship",
),
}
@@ -0,0 +1,42 @@
---
source: tests/typing_surface/create_m2n.rs
assertion_line: 25
description: "input=\"create m:n relationship \" cursor=24"
expression: "& a"
---
Assessment {
input: "create m:n relationship ",
cursor: 24,
state: IncompleteAtEof,
hint: Some(
Candidates {
items: [
Candidate {
text: "from",
kind: Keyword,
mode: Simple,
},
],
selected: None,
},
),
completion: Some(
Completion {
replaced_range: (
24,
24,
),
partial_prefix: "",
candidates: [
Candidate {
text: "from",
kind: Keyword,
mode: Simple,
},
],
},
),
parse_result: Err(
"Invalid(at_eof)",
),
}
@@ -1,5 +1,6 @@
--- ---
source: tests/typing_surface/create_table.rs source: tests/typing_surface/create_table.rs
assertion_line: 13
description: "input=\"create \" cursor=7" description: "input=\"create \" cursor=7"
expression: "& a" expression: "& a"
--- ---
@@ -13,6 +14,11 @@ Assessment {
Candidate { Candidate {
text: "table", text: "table",
kind: Keyword, kind: Keyword,
mode: Simple,
},
Candidate {
text: "m:n",
kind: Keyword,
mode: Both, mode: Both,
}, },
], ],
@@ -30,6 +36,11 @@ Assessment {
Candidate { Candidate {
text: "table", text: "table",
kind: Keyword, kind: Keyword,
mode: Simple,
},
Candidate {
text: "m:n",
kind: Keyword,
mode: Both, mode: Both,
}, },
], ],
@@ -1,5 +1,6 @@
--- ---
source: tests/typing_surface/create_table.rs source: tests/typing_surface/create_table.rs
assertion_line: 48
description: "input=\"create table Customers with \" cursor=28" description: "input=\"create table Customers with \" cursor=28"
expression: "& a" expression: "& a"
--- ---
@@ -13,7 +14,7 @@ Assessment {
Candidate { Candidate {
text: "pk", text: "pk",
kind: Keyword, kind: Keyword,
mode: Both, mode: Simple,
}, },
], ],
selected: None, selected: None,
@@ -30,7 +31,7 @@ Assessment {
Candidate { Candidate {
text: "pk", text: "pk",
kind: Keyword, kind: Keyword,
mode: Both, mode: Simple,
}, },
], ],
}, },