diff --git a/docs/adr/0045-mn-convenience.md b/docs/adr/0045-mn-convenience.md
index 971dd6e..a1d3f01 100644
--- a/docs/adr/0045-mn-convenience.md
+++ b/docs/adr/0045-mn-convenience.md
@@ -2,7 +2,38 @@
## 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
option (compound-over-FKs junction PK; `CASCADE` actions; auto-name +
optional `as`; both modes). Two follow-up points were also confirmed
diff --git a/docs/adr/README.md b/docs/adr/README.md
index abba83d..99b3a78 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -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-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
+- [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 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
diff --git a/docs/requirements.md b/docs/requirements.md
index fea72ef..625ce85 100644
--- a/docs/requirements.md
+++ b/docs/requirements.md
@@ -276,9 +276,23 @@ since ADR-0027.)
the same via drop + add today; one-step modify is a small
follow-up using the existing rebuild-table machinery. ADR
pending.
-- [ ] **C4** Convenience: `create m:n relationship from to
+- [x] **C4** Convenience: `create m:n relationship from to
` produces an auto-named junction table the user can rename;
pulls primary keys and FK definitions automatically.
+ *(Done 2026-06-10 via **ADR-0045**. `create m:n relationship from
+ to [as ]` 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.
*(ADR-0014. INSERT short and long forms, UPDATE/DELETE with
required WHERE plus `--all-rows` opt-in, `show data `,
diff --git a/src/app.rs b/src/app.rs
index 00e7bae..1e6ff1c 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2058,6 +2058,10 @@ impl App {
// column for a compound FK (ADR-0043).
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 {
RelationshipSelector::Endpoints {
parent_table,
@@ -2927,13 +2931,15 @@ mod tests {
#[test]
fn tab_at_word_boundary_inserts_next_expected_keyword() {
- // `create ` → expects only `table`. Single candidate;
- // insert "table " with space, no memo.
+ // `change ` → expects only `column`. Single candidate;
+ // 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();
- type_str(&mut app, "create ");
+ type_str(&mut app, "change ");
let actions = app.update(key(KeyCode::Tab));
assert!(actions.is_empty());
- assert_eq!(app.input, "create table ");
+ assert_eq!(app.input, "change column ");
assert!(app.last_completion.is_none());
}
@@ -3080,17 +3086,19 @@ mod tests {
// Stage-8 follow-up #2 (testing-round-2): the
// single-candidate-no-memo design lets the user chain
// Tabs through unique completions without getting
- // stuck. From "cr", Tab → "create ", Tab → "create
- // table ". (Round 5 added the app-lifecycle commands —
+ // stuck. From "ch", Tab → "change ", Tab → "change
+ // column ". (Round 5 added the app-lifecycle commands —
// single-letter prefixes like `i` are now ambiguous
// (`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();
- type_str(&mut app, "cr");
+ type_str(&mut app, "ch");
app.update(key(KeyCode::Tab));
- assert_eq!(app.input, "create ");
+ assert_eq!(app.input, "change ");
app.update(key(KeyCode::Tab));
- assert_eq!(app.input, "create table ");
+ assert_eq!(app.input, "change column ");
assert!(app.last_completion.is_none());
}
diff --git a/src/completion.rs b/src/completion.rs
index ef74daa..5ca535a 100644
--- a/src/completion.rs
+++ b/src/completion.rs
@@ -31,6 +31,7 @@ use crate::mode::Mode;
/// fragments the user thinks of as a single phrase:
///
/// - `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
/// (ADR-0035 §6.3; the grammar has a dedicated branch so the per-word
/// `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
/// alongside it.
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).
///
@@ -1346,12 +1347,19 @@ mod tests {
fn at_token_boundary_offers_next_expected_keyword() {
// After `create ` advanced mode offers `table` (valid in both
// 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`.
let cs = cands("create ", 7);
assert_eq!(
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()
+ ]
);
}
diff --git a/src/db.rs b/src/db.rs
index df090fa..48a85e0 100644
--- a/src/db.rs
+++ b/src/db.rs
@@ -605,6 +605,13 @@ enum Request {
source: Option,
reply: oneshot::Sender>,
},
+ CreateM2nRelationship {
+ t1: String,
+ t2: String,
+ name: Option,
+ source: Option,
+ reply: oneshot::Sender>,
+ },
DropRelationship {
selector: RelationshipSelector,
source: Option,
@@ -1420,6 +1427,29 @@ impl Database {
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,
+ source: Option,
+ ) -> Result {
+ 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(
&self,
selector: RelationshipSelector,
@@ -2347,6 +2377,24 @@ fn handle_request(
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 {
selector,
source,
@@ -3394,6 +3442,14 @@ fn do_create_table(
foreign_keys: &[SqlForeignKey],
) -> Result {
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
+ // ` (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() {
// SQLite requires at least one column. The DSL grammar
// already prevents this, but defending here too keeps
@@ -7277,6 +7333,101 @@ fn resolve_create_table_fks(
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 {
+ 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 = Vec::new();
+ let mut primary_key: Vec = Vec::new();
+ let mut foreign_keys: Vec = 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 = 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 ` 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)]
fn do_add_relationship(
conn: &Connection,
@@ -10397,6 +10548,26 @@ mod tests {
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]
async fn drop_table_removes_it_from_list() {
let db = db();
diff --git a/src/dsl/command.rs b/src/dsl/command.rs
index 9378448..68046e4 100644
--- a/src/dsl/command.rs
+++ b/src/dsl/command.rs
@@ -277,6 +277,18 @@ pub enum Command {
on_update: ReferentialAction,
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,
+ },
/// Drop a relationship by either user-given/auto-generated
/// name, or by positional reference to the FK endpoints.
DropRelationship {
@@ -915,6 +927,7 @@ impl Command {
Self::RenameColumn { .. } => "rename column",
Self::ChangeColumnType { .. } => "change column",
Self::AddRelationship { .. } => "add relationship",
+ Self::CreateM2nRelationship { .. } => "create m:n relationship",
Self::DropRelationship { .. } => "drop relationship",
Self::AddIndex { .. } => "add index",
Self::DropIndex { .. } => "drop index",
@@ -991,6 +1004,9 @@ impl Command {
// table's "Referenced by" entry, which is what the
// user looks at to confirm the relationship.
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 {
RelationshipSelector::Endpoints { parent_table, .. } => parent_table,
// For a named drop we don't know the parent table
diff --git a/src/dsl/grammar/ddl.rs b/src/dsl/grammar/ddl.rs
index 022521c..0167093 100644
--- a/src/dsl/grammar/ddl.rs
+++ b/src/dsl/grammar/ddl.rs
@@ -1362,6 +1362,75 @@ pub static CREATE: CommandNode = CommandNode {
help_id: Some("ddl.create"),
usage_ids: &["parse.usage.create_table"],};
+// =================================================================
+// create_m2n — `create m:n relationship from to [as ]`
+// (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 ` — 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 {
+ 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 —
/// a structural impossibility given the grammar, defended anyway.
fn sql_col_type_without_name() -> ValidationError {
diff --git a/src/dsl/grammar/mod.rs b/src/dsl/grammar/mod.rs
index d68fc3b..30a5b3b 100644
--- a/src/dsl/grammar/mod.rs
+++ b/src/dsl/grammar/mod.rs
@@ -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) {
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`,
// `index`, `table`, `relationship` — matched against the
// usage key's suffix.
@@ -706,6 +712,7 @@ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
(&ddl::RENAME, CommandCategory::Simple),
(&ddl::CHANGE, CommandCategory::Simple),
(&ddl::CREATE, CommandCategory::Simple),
+ (&ddl::CREATE_M2N, CommandCategory::Simple),
(&data::SHOW, CommandCategory::Simple),
(&data::INSERT, CommandCategory::Simple),
(&data::UPDATE, CommandCategory::Simple),
@@ -852,6 +859,13 @@ mod usage_key_tests {
),
("show data T", "parse.usage.show_data"),
("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 {
assert_eq!(
diff --git a/src/dsl/walker/highlight.rs b/src/dsl/walker/highlight.rs
index e5a4b9a..f2bd732 100644
--- a/src/dsl/walker/highlight.rs
+++ b/src/dsl/walker/highlight.rs
@@ -211,6 +211,21 @@ mod tests {
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 = runs.iter().map(|(_, _, c)| *c).collect();
+ assert!(kinds.contains(&HighlightClass::Keyword), "keywords highlighted: {runs:?}");
+ assert!(kinds.contains(&HighlightClass::Identifier), "table names highlighted: {runs:?}");
+ }
+
#[test]
fn keyword_plus_identifier_via_walker() {
// `show data Customers` walks end-to-end.
diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs
index d22e750..d3cf55b 100644
--- a/src/dsl/walker/mod.rs
+++ b/src/dsl/walker/mod.rs
@@ -406,13 +406,28 @@ pub fn completion_probe_in_mode(
// Mismatch and is naturally skipped — the viability check is the
// gate, not the cursor depth.
let mut expected_modes = vec![crate::completion::ModeClass::Both; expected.len()];
- if mode == crate::mode::Mode::Advanced {
+ {
let s = skip_whitespace(source, 0);
if let Some((kw_start, kw_end)) = consume_ident(source, s) {
let entry = &source[kw_start..kw_end];
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)
let mut tally: Vec<(&'static str, bool, bool)> = Vec::new();
// Continuations that aren't keyword/literal-shaped
@@ -422,6 +437,13 @@ pub fn completion_probe_in_mode(
// for punctuation defaults to `Both`.
let mut punct_tally: Vec = Vec::new();
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);
sctx.mode = mode;
let (res, _) =
@@ -2720,13 +2742,46 @@ fn decide(
// appended at the rendering layer (see
// `advanced_alternative_note`), combining the DSL fix with
// the mode hint.
- match simple.first() {
- Some(&(sidx, snode)) => Decision::Commit { idx: sidx, node: snode },
- None => {
- let primary = candidates.first().map_or("", |(_, n, _)| n.entry.primary);
- Decision::ThisIsSql { primary }
+ if simple.is_empty() {
+ let primary = candidates.first().map_or("", |(_, n, _)| n.entry.primary);
+ return 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 => {
// Advanced candidates first, DSL as the fallback.
diff --git a/src/echo.rs b/src/echo.rs
index e320cb7..04461ca 100644
--- a/src/echo.rs
+++ b/src/echo.rs
@@ -15,6 +15,7 @@
use crate::app::EffectiveMode;
use crate::dsl::ReferentialAction;
+use crate::dsl::types::Type;
use crate::dsl::Command;
use crate::dsl::command::{
ColumnSpec, CompareOp, Constraint, ConstraintKind, Expr, Operand, Predicate, RowFilter,
@@ -286,6 +287,31 @@ pub(crate) fn render_add_relationship(
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, Vec)],
+) -> String {
+ let mut parts: Vec =
+ 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 DROP CONSTRAINT ` — the `drop relationship`
/// echo (ADR-0038 §7 Bucket B). The runtime resolves both `name` (for an
/// `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 ------------------------------------
#[test]
diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs
index 4855855..f8b256c 100644
--- a/src/friendly/keys.rs
+++ b/src/friendly/keys.rs
@@ -190,6 +190,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("help.app.redo", &[]),
("help.app.copy", &[]),
("help.ddl.create", &[]),
+ ("help.ddl.create_m2n", &[]),
("help.ddl.sql_create_table", &[]),
("help.ddl.sql_drop_table", &[]),
("help.ddl.sql_create_index", &[]),
@@ -277,6 +278,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("parse.usage.add_relationship", &[]),
("parse.usage.change_column", &[]),
("parse.usage.create_table", &[]),
+ ("parse.usage.create_m2n", &[]),
("parse.usage.sql_create_table", &[]),
("parse.usage.sql_drop_table", &[]),
("parse.usage.sql_create_index", &[]),
diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml
index 2f2e432..2ebed9e 100644
--- a/src/friendly/strings/en-US.yaml
+++ b/src/friendly/strings/en-US.yaml
@@ -279,6 +279,9 @@ help:
ddl:
create: |-
create table with pk [(), ...] — create a table
+ create_m2n: |-
+ create m:n relationship from to [as ]
+ — build a junction table linking two tables
sql_create_table: |-
create table [if not exists] (
[not null] [unique] [primary key] [default ] [check ()] [references [(
)]], ...
@@ -523,6 +526,7 @@ parse:
# placeholders. ADR-0009's surface conventions apply.
usage:
create_table: "create table with pk [()[, ...]]"
+ create_m2n: "create m:n relationship from to [as ]"
# Terse one-line synopsis (issue #12): the full grammar — every
# column- and table-level constraint — lives in `help.ddl.sql_create_table`.
sql_create_table: "create table [if not exists] ( [constraints], ...)"
diff --git a/src/runtime.rs b/src/runtime.rs
index 0ec720b..488020c 100644
--- a/src/runtime.rs
+++ b/src/runtime.rs
@@ -1832,6 +1832,24 @@ fn build_schema_echo(
.map(|(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 =
+ desc.columns.iter().filter(|c| c.primary_key).map(|c| c.name.clone()).collect();
+ let foreign_keys: Vec<(Vec, String, Vec)> = 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
// variants like `Sql*` / `ShowTable`) routes through the existing
// `echo::command_to_sql` — wrapping its `Option` to fit the
@@ -2657,6 +2675,10 @@ async fn execute_command_typed(
)
.await
.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
.drop_relationship(selector, src)
.await
diff --git a/tests/it/m2n.rs b/tests/it/m2n.rs
new file mode 100644
index 0000000..df0b6d1
--- /dev/null
+++ b/tests/it/m2n.rs
@@ -0,0 +1,418 @@
+//! Integration tests for the m:n convenience command (C4 / ADR-0045):
+//! `create m:n relationship from to [as ]`.
+//!
+//! Covers parse, junction generation (columns / compound PK / two
+//! enforced FKs), the `as ` 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}");
+ });
+}
diff --git a/tests/it/main.rs b/tests/it/main.rs
index f2e9a57..a6d300d 100644
--- a/tests/it/main.rs
+++ b/tests/it/main.rs
@@ -19,6 +19,7 @@ mod iteration4a_rebuild_command;
mod iteration4b_lifecycle_commands;
mod iteration5_export_import;
mod iteration6_resume_history;
+mod m2n;
mod parse_error_pedagogy;
mod project_lifecycle;
mod replay_command;
diff --git a/tests/typing_surface/create_m2n.rs b/tests/typing_surface/create_m2n.rs
new file mode 100644
index 0000000..10abc06
--- /dev/null
+++ b/tests/typing_surface/create_m2n.rs
@@ -0,0 +1,73 @@
+//! Matrix coverage for `create m:n relationship from to
+//! [as ]` (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);
+}
diff --git a/tests/typing_surface/mod.rs b/tests/typing_surface/mod.rs
index 4551501..c2d4307 100644
--- a/tests/typing_surface/mod.rs
+++ b/tests/typing_surface/mod.rs
@@ -35,6 +35,7 @@ pub mod create_table;
pub mod drop_column;
pub mod drop_relationship;
pub mod add_relationship;
+pub mod create_m2n;
pub mod index_ops;
pub mod constraints;
pub mod rename_change_column;
@@ -224,6 +225,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String {
RenameColumn { .. } => "RenameColumn".into(),
ChangeColumnType { .. } => "ChangeColumnType".into(),
AddRelationship { .. } => "AddRelationship".into(),
+ CreateM2nRelationship { .. } => "CreateM2nRelationship".into(),
DropRelationship { .. } => "DropRelationship".into(),
AddIndex { .. } => "AddIndex".into(),
DropIndex { .. } => "DropIndex".into(),
diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_as_keyword_is_incomplete@after_as.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_as_keyword_is_incomplete@after_as.snap
new file mode 100644
index 0000000..11f0118
--- /dev/null
+++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_as_keyword_is_incomplete@after_as.snap
@@ -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)",
+ ),
+}
diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_create_offers_table_and_m2n@after_create.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_create_offers_table_and_m2n@after_create.snap
new file mode 100644
index 0000000..4a0f4f9
--- /dev/null
+++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_create_offers_table_and_m2n@after_create.snap
@@ -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)",
+ ),
+}
diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_from_offers_table_names@after_from.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_from_offers_table_names@after_from.snap
new file mode 100644
index 0000000..a0c555b
--- /dev/null
+++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_from_offers_table_names@after_from.snap
@@ -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)",
+ ),
+}
diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_to_offers_table_names@after_to.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_to_offers_table_names@after_to.snap
new file mode 100644
index 0000000..4055c73
--- /dev/null
+++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_to_offers_table_names@after_to.snap
@@ -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)",
+ ),
+}
diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__complete_create_m2n_parses@complete.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__complete_create_m2n_parses@complete.snap
new file mode 100644
index 0000000..d4b1054
--- /dev/null
+++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__complete_create_m2n_parses@complete.snap
@@ -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",
+ ),
+}
diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__create_m2n_with_as_name_parses@with_as_name.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__create_m2n_with_as_name_parses@with_as_name.snap
new file mode 100644
index 0000000..05773bd
--- /dev/null
+++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__create_m2n_with_as_name_parses@with_as_name.snap
@@ -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",
+ ),
+}
diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__m2n_relationship_keyword_sequence_is_incomplete@after_relationship_keyword.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__m2n_relationship_keyword_sequence_is_incomplete@after_relationship_keyword.snap
new file mode 100644
index 0000000..62fb479
--- /dev/null
+++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__m2n_relationship_keyword_sequence_is_incomplete@after_relationship_keyword.snap
@@ -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)",
+ ),
+}
diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_table__after_create_expects_table@after_create.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_table__after_create_expects_table@after_create.snap
index f2e64e4..39cca2e 100644
--- a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_table__after_create_expects_table@after_create.snap
+++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_table__after_create_expects_table@after_create.snap
@@ -1,5 +1,6 @@
---
source: tests/typing_surface/create_table.rs
+assertion_line: 13
description: "input=\"create \" cursor=7"
expression: "& a"
---
@@ -13,6 +14,11 @@ Assessment {
Candidate {
text: "table",
kind: Keyword,
+ mode: Simple,
+ },
+ Candidate {
+ text: "m:n",
+ kind: Keyword,
mode: Both,
},
],
@@ -30,6 +36,11 @@ Assessment {
Candidate {
text: "table",
kind: Keyword,
+ mode: Simple,
+ },
+ Candidate {
+ text: "m:n",
+ kind: Keyword,
mode: Both,
},
],
diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_table__after_with_expects_pk@after_with.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_table__after_with_expects_pk@after_with.snap
index 1da5358..600150c 100644
--- a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_table__after_with_expects_pk@after_with.snap
+++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_table__after_with_expects_pk@after_with.snap
@@ -1,5 +1,6 @@
---
source: tests/typing_surface/create_table.rs
+assertion_line: 48
description: "input=\"create table Customers with \" cursor=28"
expression: "& a"
---
@@ -13,7 +14,7 @@ Assessment {
Candidate {
text: "pk",
kind: Keyword,
- mode: Both,
+ mode: Simple,
},
],
selected: None,
@@ -30,7 +31,7 @@ Assessment {
Candidate {
text: "pk",
kind: Keyword,
- mode: Both,
+ mode: Simple,
},
],
},