From 8bd43ccadfa9d2032cd0a9db1e17f8e29a5e45ec Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 14:26:33 +0000 Subject: [PATCH] feat: create m:n relationship convenience command (C4, ADR-0045) `create m:n relationship from to [as ]` 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. --- docs/adr/0045-mn-convenience.md | 33 +- docs/adr/README.md | 2 +- docs/requirements.md | 16 +- src/app.rs | 28 +- src/completion.rs | 14 +- src/db.rs | 171 +++++++ src/dsl/command.rs | 16 + src/dsl/grammar/ddl.rs | 69 +++ src/dsl/grammar/mod.rs | 14 + src/dsl/walker/highlight.rs | 15 + src/dsl/walker/mod.rs | 71 ++- src/echo.rs | 54 +++ src/friendly/keys.rs | 2 + src/friendly/strings/en-US.yaml | 4 + src/runtime.rs | 22 + tests/it/m2n.rs | 418 ++++++++++++++++++ tests/it/main.rs | 1 + tests/typing_surface/create_m2n.rs | 73 +++ tests/typing_surface/mod.rs | 2 + ...ter_as_keyword_is_incomplete@after_as.snap | 20 + ...ate_offers_table_and_m2n@after_create.snap | 52 +++ ...er_from_offers_table_names@after_from.snap | 52 +++ ..._after_to_offers_table_names@after_to.snap | 52 +++ ...__complete_create_m2n_parses@complete.snap | 20 + ..._m2n_with_as_name_parses@with_as_name.snap | 20 + ...incomplete@after_relationship_keyword.snap | 42 ++ ...ter_create_expects_table@after_create.snap | 11 + ...ble__after_with_expects_pk@after_with.snap | 5 +- 28 files changed, 1273 insertions(+), 26 deletions(-) create mode 100644 tests/it/m2n.rs create mode 100644 tests/typing_surface/create_m2n.rs create mode 100644 tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_as_keyword_is_incomplete@after_as.snap create mode 100644 tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_create_offers_table_and_m2n@after_create.snap create mode 100644 tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_from_offers_table_names@after_from.snap create mode 100644 tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_to_offers_table_names@after_to.snap create mode 100644 tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__complete_create_m2n_parses@complete.snap create mode 100644 tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__create_m2n_with_as_name_parses@with_as_name.snap create mode 100644 tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__m2n_relationship_keyword_sequence_is_incomplete@after_relationship_keyword.snap 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, }, ], },