feat: create m:n relationship convenience command (C4, ADR-0045)
`create m:n relationship from <T1> to <T2> [as <name>]` generates a
junction table with one FK column per parent PK column ({table}_{pkcol},
typed via fk_target_type), a compound PK over them, and two CASCADE 1:n
relationships -- all in one do_create_table call = one undo step.
Auto-named {T1}_{T2} (optional `as`), both modes, compound-parent PKs
supported (ADR-0043). Self-referential m:n / PK-less parent / internal
junction name / name collision all refused.
Wired across every surface: grammar (separate CREATE_M2N node), worker
executor, runtime dispatch, completion ("m:n" composite), hints,
highlighting, help + usage catalog + disambiguator, and the advanced-mode
DSL->SQL teaching echo (render_create_m2n, round-trips as valid SQL).
Generalized/fixed framework assumptions the build + two /runda passes
surfaced (all behaviour-preserving for existing commands):
- simple-mode dispatch committed simple.first() unconditionally -> tries
candidates, so `create table` no longer shadows `create m:n`.
- the completion continuation-merge was advanced-only -> runs in simple
mode too when an entry word has >1 DSL form (gated simple_count>1).
- do_create_table now rejects internal `__rdbms_*` names (closes a
pre-existing hole on the DSL create-table path too, not just m:n).
- usage disambiguator now recognizes the `m:n` opener.
Tests: 14 integration (tests/it/m2n.rs), 7 typing-surface matrix, echo /
highlight / usage / internal-name units. Closes C4.
2237 pass / 0 fail / 1 ignored. Clippy clean.
This commit is contained in:
@@ -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
|
||||
|
||||
+1
-1
@@ -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 <P>.(a, b) to <C>.(x, y)` (single form unchanged), SQL `FOREIGN KEY (x, y) REFERENCES P(a, b)` (extend the existing one-cap lists; bare table-level FK auto-expands to the parent PK when arities match). **Storage — no migration (back-compat not required, user-confirmed 2026-06-09; no installed base):** the relationship endpoint joins the list convention `project.yaml` *already* uses — `columns: [a, b]` like `primary_key: [id]` and index `columns: [...]` (the endpoint was the lone scalar `column:` holdout); the metadata `TEXT` columns are unchanged and store the list **comma-joined** (`a,b`; the bare name for single — safe because identifiers are `[A-Za-z0-9_]+`). No F3 migrator, no version bump; accepted trade-off is that a pre-change `project.yaml` with relationships won't load (clean cutover). In-memory model goes list-based (`Vec<String>`) through all six layers; the enforced FK is the rebuilt child-table DDL (`FOREIGN KEY (a,b) REFERENCES P(x,y)`), one relationship = one undo step (ADR-0013). Genuine forks escalated: matching policy (full-PK vs subset), storage (house-style uniform lists vs normalized table), DSL syntax (parenthesized vs repeated-dotted), bare-SQL-FK auto-expansion. OOS: subset/non-PK (UNIQUE-targeted) FK references; any single-column behaviour change
|
||||
- [ADR-0044 — Relationship visualization (two-table connector diagrams)](0044-relationship-visualization.md) — **Accepted 2026-06-09; implemented 2026-06-10** (closes `requirements.md` V1; second `/runda` pass over the implementation; §3 last-resort helper line considered and rejected). Resolves **ADR-0016 OOS-1** and closes the open half of `requirements.md` **V1** ("a selected relationship as two tables joined by a line"). Renders a relationship as **Style A** (two structure boxes + connector). **Reach = "relationship-relevant"** (user-chosen over global / show-only): diagrams on the surfaces where the relationship is the *subject* — `show relationship <name>` (one full diagram), `show table <T>` (T's structure box then a **Relationships** section of **stacked compact** per-relationship diagrams — chosen over a focal-centred subgraph: no crossing lines, scales via scroll, two-boxes-wide fits any terminal), and relationship DDL echoes (`add`/`drop`/`modify relationship`); incidental DDL echoes (`add column`, `drop index`, `change column`, plain `create table`) keep the terse prose, via a `Diagram`|`Prose` render mode on `render_structure`. Reading convention **child(FK)-left / parent-right, arrow →, `n`…`1` cardinality**, applied uniformly; every box gets a **bold title row + rule** so the name can't read as a column. **Compound FKs** (ADR-0043) route one connector per positional pair + an explicit pairing line. **Width-aware** (first in the codebase) but **App-side**: `render_structure`/diagram rendering runs in `app.rs` (the worker only returns `TableDescription`s), a new `App::last_output_width` (set from `ui.rs`) drives side-by-side vs a **vertical-stack** fallback + last-resort "run `show relationship`" pointer; rendered once at command time, **no live reflow** (V4). `show relationship`'s worker path (`do_show_one`, prose-only) is restructured to return both endpoint `TableDescription`s. Styling reuses **ADR-0028** App-side styled runs (new classes: table-name/key/connector/cardinality/action) — no worker→UI contract change. **Partially supersedes ADR-0016 §5** (prose block replaced on relationship-subject surfaces, retained on incidental ones); extends §4 (layout width-awareness, still no cell truncation) and §6 (per-span theming). Tests: insta snapshots (single, compound, vertical fallback, helper line, self-referential, multi-rel `show table`) + width-threshold/routing unit tests + Tier-3 wiring; enumerated prose-fallout updates (`output_render.rs:121/135/793`, the relationships snapshot, `walking_skeleton.rs:477/530`). A `/runda` DA pass corrected three inverted-architecture claims (App-side rendering, untracked width, prose-in-worker show-relationship) before acceptance. OOS: user-configurable display setting (OOS-7), live reflow (V4), whole-DB ER export (V3), m:n (C4), ASCII fallback (ADR-0016 OOS-5)
|
||||
- [ADR-0045 — `create m:n relationship` convenience command (C4)](0045-mn-convenience.md) — **Accepted 2026-06-10** (closes `requirements.md` **C4**; all forks user-confirmed + a `/runda` DA pass that verified the `do_create_table` reuse against code and corrected the "no PK-less tables" assumption — advanced SQL `create table t (a int)` has none, so a parent-PK guard is retained). `create m:n relationship from <T1> to <T2> [as <name>]` generates a junction table with one FK column per parent PK column, a **compound PK over all the FK columns** (the textbook junction — the pair is unique, no duplicate links), and **two 1:n relationships**, all in **one transaction = one undo step** (built by reusing `do_create_table`, which already takes `foreign_keys` + writes relationship metadata — no batch bracketing). Forks all user-chosen: junction PK = compound-over-FKs (vs surrogate serial / no PK); referential actions = **`CASCADE`** on delete+update (vs NO ACTION / RESTRICT); naming = auto `{T1}_{T2}` + optional `as` (vs auto-only); available in **both modes** (Simple-category DSL, like the sibling relationship commands). FK columns named `{parent_table}_{pk_column}` (disambiguates shared `id`; generalises to compound parents via ADR-0043), typed via `fk_target_type` (ADR-0011). A distinct `Command::CreateM2nRelationship` (not lowered to `CreateTable`) preserves command identity (X5) and lets the teaching echo speak in m:n terms. Cross-cutting wiring enumerated: separate `CREATE_M2N` `CommandNode` (own `help_id`/`usage_ids`), `("m","m:n")` completion composite, `HintMode`s, grammar-driven highlighting, `help`/`help create`, `parse_error_pedagogy` near-miss matrix, teaching echo. OOS: **self-referential m:n** (`from T to T`) refused outright (user-confirmed "full stop" — directional column-naming is more than this beginner convenience warrants); per-relationship action overrides; extra junction payload columns; m:n diagram echo; renaming the auto-generated relationships
|
||||
- [ADR-0045 — `create m:n relationship` convenience command (C4)](0045-mn-convenience.md) — **Accepted + implemented 2026-06-10** (closes `requirements.md` **C4**; all forks user-confirmed + a `/runda` DA pass that verified the `do_create_table` reuse against code and corrected the "no PK-less tables" assumption — advanced SQL `create table t (a int)` has none, so a parent-PK guard is retained). Implementation corrected a second ADR premise: "the walker already dispatches multiple nodes per entry word" held only in *advanced* mode — two simple-mode spots (dispatcher `decide`, completion continuation-merge) assumed ≤1 DSL form per entry word and were generalized **behaviour-preservingly** (dispatch reduces to the old single-candidate commit; completion merge gated on `simple_count > 1`). Junction echo wired (`render_create_m2n`, round-trips as SQL). `create m:n relationship from <T1> to <T2> [as <name>]` generates a junction table with one FK column per parent PK column, a **compound PK over all the FK columns** (the textbook junction — the pair is unique, no duplicate links), and **two 1:n relationships**, all in **one transaction = one undo step** (built by reusing `do_create_table`, which already takes `foreign_keys` + writes relationship metadata — no batch bracketing). Forks all user-chosen: junction PK = compound-over-FKs (vs surrogate serial / no PK); referential actions = **`CASCADE`** on delete+update (vs NO ACTION / RESTRICT); naming = auto `{T1}_{T2}` + optional `as` (vs auto-only); available in **both modes** (Simple-category DSL, like the sibling relationship commands). FK columns named `{parent_table}_{pk_column}` (disambiguates shared `id`; generalises to compound parents via ADR-0043), typed via `fk_target_type` (ADR-0011). A distinct `Command::CreateM2nRelationship` (not lowered to `CreateTable`) preserves command identity (X5) and lets the teaching echo speak in m:n terms. Cross-cutting wiring enumerated: separate `CREATE_M2N` `CommandNode` (own `help_id`/`usage_ids`), `("m","m:n")` completion composite, `HintMode`s, grammar-driven highlighting, `help`/`help create`, `parse_error_pedagogy` near-miss matrix, teaching echo. OOS: **self-referential m:n** (`from T to T`) refused outright (user-confirmed "full stop" — directional column-naming is more than this beginner convenience warrants); per-relationship action overrides; extra junction payload columns; m:n diagram echo; renaming the auto-generated relationships
|
||||
|
||||
+15
-1
@@ -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 <T1> to
|
||||
- [x] **C4** Convenience: `create m:n relationship from <T1> to
|
||||
<T2>` produces an auto-named junction table the user can rename;
|
||||
pulls primary keys and FK definitions automatically.
|
||||
*(Done 2026-06-10 via **ADR-0045**. `create m:n relationship from
|
||||
<T1> to <T2> [as <name>]` builds a junction table with one FK column
|
||||
per parent PK column (`{table}_{pkcol}`, typed via `fk_target_type`),
|
||||
a **compound PK** over them, and two **`CASCADE`** 1:n relationships
|
||||
— all in one `do_create_table` call = one undo step. Auto-named
|
||||
`{T1}_{T2}` (optional `as`), available in both modes, compound-parent
|
||||
PKs supported (ADR-0043). Self-referential m:n refused; PK-less parent
|
||||
refused. Wired across every surface — completion (`m:n` composite),
|
||||
hints, highlighting, `help`/usage, and the advanced-mode DSL→SQL
|
||||
teaching echo (the generated `CREATE TABLE … FOREIGN KEY …`). 9
|
||||
integration + 7 typing-surface + echo/parse unit tests. The build
|
||||
surfaced — and fixed — two latent simple-mode dispatch/completion
|
||||
assumptions ("≤1 DSL form per entry word"), now generalized
|
||||
behaviour-preservingly.)*
|
||||
- [x] **C5** Data operations: insert / update / delete via DSL.
|
||||
*(ADR-0014. INSERT short and long forms, UPDATE/DELETE with
|
||||
required WHERE plus `--all-rows` opt-in, `show data <T>`,
|
||||
|
||||
+18
-10
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
+11
-3
@@ -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()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -605,6 +605,13 @@ enum Request {
|
||||
source: Option<String>,
|
||||
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
||||
},
|
||||
CreateM2nRelationship {
|
||||
t1: String,
|
||||
t2: String,
|
||||
name: Option<String>,
|
||||
source: Option<String>,
|
||||
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
||||
},
|
||||
DropRelationship {
|
||||
selector: RelationshipSelector,
|
||||
source: Option<String>,
|
||||
@@ -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<String>,
|
||||
source: Option<String>,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
let (reply, recv) = oneshot::channel();
|
||||
self.send(Request::CreateM2nRelationship {
|
||||
t1,
|
||||
t2,
|
||||
name,
|
||||
source,
|
||||
reply,
|
||||
})
|
||||
.await?;
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
pub async fn drop_relationship(
|
||||
&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<TableDescription, DbError> {
|
||||
debug!(table = %name, cols = columns.len(), pk = ?primary_key, "create_table");
|
||||
// A new table may not take an internal `__rdbms_*` name (it would be
|
||||
// filtered out of `list_tables` — a hidden orphan). The advanced-SQL
|
||||
// create path rejects this at parse, but the simple-mode DSL
|
||||
// `TABLE_NAME_NEW` slot has no validator, and `create m:n … as
|
||||
// <name>` (ADR-0045) reaches here too — so the shared executor is the
|
||||
// single place that closes every path (issue raised by the ADR-0045
|
||||
// /runda pass).
|
||||
reject_internal_table_name(name)?;
|
||||
if columns.is_empty() {
|
||||
// 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<TableDescription, DbError> {
|
||||
debug!(t1 = %t1, t2 = %t2, name = ?name, "create_m2n_relationship");
|
||||
// Canonicalize both parents (refuse non-existent / internal tables).
|
||||
let canon_t1 = require_canonical_table(conn, t1)?;
|
||||
let t1 = canon_t1.as_str();
|
||||
let canon_t2 = require_canonical_table(conn, t2)?;
|
||||
let t2 = canon_t2.as_str();
|
||||
|
||||
// Self-referential m:n is OOS (ADR-0045): the two FK column sets
|
||||
// would collide on `{T}_{pkcol}`, needing directional names this
|
||||
// beginner convenience deliberately avoids.
|
||||
if t1.eq_ignore_ascii_case(t2) {
|
||||
return Err(DbError::Unsupported(format!(
|
||||
"an m:n relationship needs two different tables (got `{t1}` twice). \
|
||||
To link a table to itself, build the junction table by hand."
|
||||
)));
|
||||
}
|
||||
|
||||
let schema1 = read_schema(conn, t1)?;
|
||||
let schema2 = read_schema(conn, t2)?;
|
||||
|
||||
// Build one FK column per parent PK column (compound parents
|
||||
// contribute one each, ADR-0043) + the compound PK + the two FKs.
|
||||
let mut columns: Vec<ColumnSpec> = Vec::new();
|
||||
let mut primary_key: Vec<String> = Vec::new();
|
||||
let mut foreign_keys: Vec<SqlForeignKey> = Vec::new();
|
||||
for (tbl, schema) in [(t1, &schema1), (t2, &schema2)] {
|
||||
// D7 parent-PK guard: advanced-mode SQL can create a PK-less
|
||||
// table; it cannot anchor an m:n relationship.
|
||||
if schema.primary_key.is_empty() {
|
||||
return Err(DbError::Unsupported(format!(
|
||||
"`{tbl}` has no primary key, so it cannot anchor an m:n relationship."
|
||||
)));
|
||||
}
|
||||
let mut child_columns: Vec<String> = Vec::new();
|
||||
for pkcol in &schema.primary_key {
|
||||
let pcol = schema
|
||||
.columns
|
||||
.iter()
|
||||
.find(|c| &c.name == pkcol)
|
||||
.ok_or_else(|| DbError::Sqlite {
|
||||
message: format!("no such column: {tbl}.{pkcol}"),
|
||||
kind: SqliteErrorKind::NoSuchColumn,
|
||||
})?;
|
||||
let pty = pcol.user_type.ok_or_else(|| {
|
||||
DbError::Unsupported("primary-key column has no user type metadata".to_string())
|
||||
})?;
|
||||
let col_name = format!("{tbl}_{pkcol}");
|
||||
columns.push(ColumnSpec::new(col_name.clone(), pty.fk_target_type()));
|
||||
primary_key.push(col_name.clone());
|
||||
child_columns.push(col_name);
|
||||
}
|
||||
foreign_keys.push(SqlForeignKey {
|
||||
name: None,
|
||||
child_columns,
|
||||
parent_table: tbl.to_string(),
|
||||
parent_columns: Some(schema.primary_key.clone()),
|
||||
on_delete: ReferentialAction::Cascade,
|
||||
on_update: ReferentialAction::Cascade,
|
||||
inline: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Junction name: explicit `as <name>` or the auto-name `{t1}_{t2}`.
|
||||
let junction = name.map_or_else(|| format!("{t1}_{t2}"), str::to_string);
|
||||
debug!(junction = %junction, cols = columns.len(), "create_m2n_relationship: building junction table");
|
||||
|
||||
do_create_table(
|
||||
conn,
|
||||
persistence,
|
||||
source,
|
||||
&junction,
|
||||
&columns,
|
||||
&primary_key,
|
||||
&[],
|
||||
&[],
|
||||
&foreign_keys,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
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();
|
||||
|
||||
@@ -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<String>,
|
||||
},
|
||||
/// 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
|
||||
|
||||
@@ -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 <T1> to <T2> [as <name>]`
|
||||
// (ADR-0045 / C4). Generates an auto-named junction table with two FKs
|
||||
// + two 1:n relationships. A *separate* `CommandNode` under the shared
|
||||
// `create` entry word (the walker dispatches both); the `m` opener is a
|
||||
// `Literal` (not a keyword) so it never shadows an identifier, mirroring
|
||||
// the `1` in `add 1:n relationship`.
|
||||
// =================================================================
|
||||
|
||||
const M2N_T1: Node = Node::Ident {
|
||||
source: IdentSource::Tables,
|
||||
role: "m2n_t1",
|
||||
validator: None,
|
||||
highlight_override: None,
|
||||
writes_table: false,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
const M2N_T2: Node = Node::Ident {
|
||||
source: IdentSource::Tables,
|
||||
role: "m2n_t2",
|
||||
validator: None,
|
||||
highlight_override: None,
|
||||
writes_table: false,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
// Optional `as <junction name>` — a *new* table name (the junction),
|
||||
// so it reuses `TABLE_NAME_NEW` (role `table_name`, `NewName` source +
|
||||
// hint). The only `table_name` role in this path, so the builder reads
|
||||
// it directly as the junction name.
|
||||
const M2N_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), TABLE_NAME_NEW];
|
||||
const M2N_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(M2N_AS_NAME_NODES));
|
||||
|
||||
const CREATE_M2N_NODES: &[Node] = &[
|
||||
Node::Literal("m"),
|
||||
Node::Punct(':'),
|
||||
Node::Word(Word::keyword("n")),
|
||||
Node::Word(Word::keyword("relationship")),
|
||||
Node::Word(Word::keyword("from")),
|
||||
M2N_T1,
|
||||
Node::Word(Word::keyword("to")),
|
||||
M2N_T2,
|
||||
M2N_AS_NAME_OPT,
|
||||
];
|
||||
const CREATE_M2N_SHAPE: Node = Node::Seq(CREATE_M2N_NODES);
|
||||
|
||||
fn build_create_m2n(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
Ok(Command::CreateM2nRelationship {
|
||||
t1: require_ident(path, "m2n_t1")?,
|
||||
t2: require_ident(path, "m2n_t2")?,
|
||||
name: ident(path, "table_name").map(str::to_string),
|
||||
})
|
||||
}
|
||||
|
||||
pub static CREATE_M2N: CommandNode = CommandNode {
|
||||
entry: Word::keyword("create"),
|
||||
shape: CREATE_M2N_SHAPE,
|
||||
ast_builder: build_create_m2n,
|
||||
help_id: Some("ddl.create_m2n"),
|
||||
usage_ids: &["parse.usage.create_m2n"],
|
||||
};
|
||||
|
||||
/// The friendly error for a column type without a preceding name —
|
||||
/// a structural impossibility given the grammar, defended anyway.
|
||||
fn sql_col_type_without_name() -> ValidationError {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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<HighlightClass> = runs.iter().map(|(_, _, c)| *c).collect();
|
||||
assert!(kinds.contains(&HighlightClass::Keyword), "keywords highlighted: {runs:?}");
|
||||
assert!(kinds.contains(&HighlightClass::Identifier), "table names highlighted: {runs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyword_plus_identifier_via_walker() {
|
||||
// `show data Customers` walks end-to-end.
|
||||
|
||||
+61
-6
@@ -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;
|
||||
// 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<char> = 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,12 +2742,45 @@ 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 => {
|
||||
if simple.is_empty() {
|
||||
let primary = candidates.first().map_or("", |(_, n, _)| n.entry.primary);
|
||||
Decision::ThisIsSql { 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 => {
|
||||
|
||||
+54
@@ -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>, String, Vec<String>)],
|
||||
) -> String {
|
||||
let mut parts: Vec<String> =
|
||||
columns.iter().map(|(n, ty)| format!("{n} {}", ty.keyword())).collect();
|
||||
parts.push(format!("PRIMARY KEY ({})", primary_key.join(", ")));
|
||||
for (child_columns, parent_table, parent_columns) in foreign_keys {
|
||||
parts.push(format!(
|
||||
"FOREIGN KEY ({}) REFERENCES {parent_table} ({}) ON DELETE CASCADE ON UPDATE CASCADE",
|
||||
child_columns.join(", "),
|
||||
parent_columns.join(", "),
|
||||
));
|
||||
}
|
||||
format!("CREATE TABLE {junction} ({})", parts.join(", "))
|
||||
}
|
||||
|
||||
/// `ALTER TABLE <C> DROP CONSTRAINT <name>` — the `drop relationship`
|
||||
/// 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]
|
||||
|
||||
@@ -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", &[]),
|
||||
|
||||
@@ -279,6 +279,9 @@ help:
|
||||
ddl:
|
||||
create: |-
|
||||
create table <T> with pk [<col>(<type>), ...] — create a table
|
||||
create_m2n: |-
|
||||
create m:n relationship from <T1> to <T2> [as <name>]
|
||||
— build a junction table linking two tables
|
||||
sql_create_table: |-
|
||||
create table [if not exists] <T> (
|
||||
<col> <type> [not null] [unique] [primary key] [default <expr>] [check (<expr>)] [references <P>[(<col>)]], ...
|
||||
@@ -523,6 +526,7 @@ parse:
|
||||
# placeholders. ADR-0009's surface conventions apply.
|
||||
usage:
|
||||
create_table: "create table <Name> with pk [<col>(<type>)[, ...]]"
|
||||
create_m2n: "create m:n relationship from <Table1> to <Table2> [as <Name>]"
|
||||
# Terse one-line synopsis (issue #12): the full grammar — every
|
||||
# column- and table-level constraint — lives in `help.ddl.sql_create_table`.
|
||||
sql_create_table: "create table [if not exists] <Name> (<col> <type> [constraints], ...)"
|
||||
|
||||
@@ -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<String> =
|
||||
desc.columns.iter().filter(|c| c.primary_key).map(|c| c.name.clone()).collect();
|
||||
let foreign_keys: Vec<(Vec<String>, String, Vec<String>)> = desc
|
||||
.outbound_relationships
|
||||
.iter()
|
||||
.map(|r| (r.local_columns.clone(), r.other_table.clone(), r.other_columns.clone()))
|
||||
.collect();
|
||||
vec![crate::echo::render_create_m2n(&desc.name, &columns, &primary_key, &foreign_keys)]
|
||||
}),
|
||||
// Everything else (Bucket A pure-Command, plus the no-echo Bucket C
|
||||
// variants like `Sql*` / `ShowTable`) routes through the existing
|
||||
// `echo::command_to_sql` — wrapping its `Option<String>` 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
|
||||
|
||||
+418
@@ -0,0 +1,418 @@
|
||||
//! Integration tests for the m:n convenience command (C4 / ADR-0045):
|
||||
//! `create m:n relationship from <T1> to <T2> [as <name>]`.
|
||||
//!
|
||||
//! Covers parse, junction generation (columns / compound PK / two
|
||||
//! enforced FKs), the `as <name>` override, a compound-PK parent,
|
||||
//! CASCADE delete, one-undo-step, self-m:n refusal, and the PK-less
|
||||
//! parent guard.
|
||||
|
||||
use rdbms_playground::db::Database;
|
||||
use rdbms_playground::dsl::command::RowFilter;
|
||||
use rdbms_playground::dsl::{parse_command, ColumnSpec, Command, Type, Value};
|
||||
use rdbms_playground::persistence::Persistence;
|
||||
use rdbms_playground::project::{self, PLAYGROUND_DB};
|
||||
|
||||
fn rt() -> tokio::runtime::Runtime {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("tokio rt")
|
||||
}
|
||||
|
||||
fn open() -> (project::Project, Database, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let project = project::open_or_create(None, Some(dir.path())).expect("project");
|
||||
let db = Database::open_with_persistence(project.db_path(), Persistence::new(project.path().to_path_buf()))
|
||||
.expect("db");
|
||||
(project, db, dir)
|
||||
}
|
||||
|
||||
fn open_with_undo() -> (project::Project, Database, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let project = project::open_or_create(None, Some(dir.path())).expect("project");
|
||||
let db = Database::open_with_persistence_and_undo(
|
||||
project.db_path(),
|
||||
Persistence::new(project.path().to_path_buf()),
|
||||
true,
|
||||
)
|
||||
.expect("db");
|
||||
(project, db, dir)
|
||||
}
|
||||
|
||||
/// A parent table `(id serial PK, label text)` — the `label` gives an
|
||||
/// insertable non-PK column (a serial-PK-only table has nothing to put
|
||||
/// in a short-form INSERT).
|
||||
async fn serial_pk_table(db: &Database, name: &str) {
|
||||
db.create_table(
|
||||
name.to_string(),
|
||||
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("label", Type::Text)],
|
||||
vec!["id".to_string()],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("create {name}: {e}"));
|
||||
}
|
||||
|
||||
/// Insert one row into a `serial_pk_table`, returning its auto-assigned id.
|
||||
async fn add_row(db: &Database, table: &str, label: &str) {
|
||||
db.insert(
|
||||
table.to_string(),
|
||||
Some(vec!["label".to_string()]),
|
||||
vec![Value::Text(label.to_string())],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("insert into {table}: {e}"));
|
||||
}
|
||||
|
||||
// ---- parse layer -----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parses_to_create_m2n_relationship() {
|
||||
match parse_command("create m:n relationship from Students to Courses").expect("parses") {
|
||||
Command::CreateM2nRelationship { t1, t2, name } => {
|
||||
assert_eq!(t1, "Students");
|
||||
assert_eq!(t2, "Courses");
|
||||
assert_eq!(name, None);
|
||||
}
|
||||
other => panic!("expected CreateM2nRelationship, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_with_as_name() {
|
||||
match parse_command("create m:n relationship from Students to Courses as Enrollments")
|
||||
.expect("parses")
|
||||
{
|
||||
Command::CreateM2nRelationship { name, .. } => assert_eq!(name.as_deref(), Some("Enrollments")),
|
||||
other => panic!("expected CreateM2nRelationship, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- junction generation ---------------------------------------
|
||||
|
||||
#[test]
|
||||
fn generates_junction_with_compound_pk_and_two_enforced_fks() {
|
||||
let (_p, db, _d) = open();
|
||||
rt().block_on(async {
|
||||
serial_pk_table(&db, "Students").await;
|
||||
serial_pk_table(&db, "Courses").await;
|
||||
|
||||
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
|
||||
.await
|
||||
.expect("create m:n");
|
||||
|
||||
// Auto-named `Students_Courses` exists.
|
||||
let tables = db.list_tables().await.unwrap();
|
||||
assert!(tables.contains(&"Students_Courses".to_string()), "tables: {tables:?}");
|
||||
|
||||
// Two FK columns, both part of the compound PK.
|
||||
let desc = db.describe_table("Students_Courses".to_string(), None).await.unwrap();
|
||||
let cols: Vec<(&str, bool)> =
|
||||
desc.columns.iter().map(|c| (c.name.as_str(), c.primary_key)).collect();
|
||||
assert_eq!(
|
||||
cols,
|
||||
vec![("Students_id", true), ("Courses_id", true)],
|
||||
"expected two FK columns forming the compound PK"
|
||||
);
|
||||
// Two outbound relationships (one per parent).
|
||||
assert_eq!(desc.outbound_relationships.len(), 2, "expected two FKs");
|
||||
|
||||
// FK enforcement: a junction row needs existing parents.
|
||||
add_row(&db, "Students", "s1").await;
|
||||
add_row(&db, "Courses", "c1").await;
|
||||
db.insert(
|
||||
"Students_Courses".to_string(),
|
||||
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
|
||||
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("valid link");
|
||||
// Duplicate link refused by the compound PK.
|
||||
let dup = db
|
||||
.insert(
|
||||
"Students_Courses".to_string(),
|
||||
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
|
||||
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(dup.is_err(), "duplicate (Students_id, Courses_id) must be refused");
|
||||
// A link to a non-existent parent is refused by the FK.
|
||||
let orphan = db
|
||||
.insert(
|
||||
"Students_Courses".to_string(),
|
||||
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
|
||||
vec![Value::Number("1".to_string()), Value::Number("99".to_string())],
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(orphan.is_err(), "link to a non-existent Course must be refused");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn as_name_overrides_the_junction_table_name() {
|
||||
let (_p, db, _d) = open();
|
||||
rt().block_on(async {
|
||||
serial_pk_table(&db, "Students").await;
|
||||
serial_pk_table(&db, "Courses").await;
|
||||
db.create_m2n_relationship(
|
||||
"Students".to_string(),
|
||||
"Courses".to_string(),
|
||||
Some("Enrollments".to_string()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("create m:n as Enrollments");
|
||||
let tables = db.list_tables().await.unwrap();
|
||||
assert!(tables.contains(&"Enrollments".to_string()), "tables: {tables:?}");
|
||||
assert!(!tables.contains(&"Students_Courses".to_string()));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_parent_pk_contributes_one_fk_column_each() {
|
||||
let (_p, db, _d) = open();
|
||||
rt().block_on(async {
|
||||
// Sections has a 2-column PK (course_id, term).
|
||||
db.create_table(
|
||||
"Sections".to_string(),
|
||||
vec![ColumnSpec::new("course_id", Type::Int), ColumnSpec::new("term", Type::Int)],
|
||||
vec!["course_id".to_string(), "term".to_string()],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
serial_pk_table(&db, "Students").await;
|
||||
|
||||
db.create_m2n_relationship("Students".to_string(), "Sections".to_string(), None, None)
|
||||
.await
|
||||
.expect("create m:n");
|
||||
|
||||
let desc = db.describe_table("Students_Sections".to_string(), None).await.unwrap();
|
||||
let names: Vec<&str> = desc.columns.iter().map(|c| c.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["Students_id", "Sections_course_id", "Sections_term"]);
|
||||
// All three form the compound PK.
|
||||
assert!(desc.columns.iter().all(|c| c.primary_key), "all columns are PK: {names:?}");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_parent_cascades_to_the_junction() {
|
||||
let (_p, db, _d) = open();
|
||||
rt().block_on(async {
|
||||
serial_pk_table(&db, "Students").await;
|
||||
serial_pk_table(&db, "Courses").await;
|
||||
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
add_row(&db, "Students", "s1").await;
|
||||
add_row(&db, "Courses", "c1").await;
|
||||
db.insert(
|
||||
"Students_Courses".to_string(),
|
||||
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
|
||||
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Deleting the student cascades to the junction (ON DELETE CASCADE).
|
||||
db.delete("Students".to_string(), RowFilter::AllRows, None).await.unwrap();
|
||||
let rows = db.query_data("Students_Courses".to_string(), None, None, None).await.unwrap();
|
||||
assert!(rows.rows.is_empty(), "junction rows should cascade-delete, got {:?}", rows.rows);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_m2n_is_one_undo_step() {
|
||||
let (_p, db, _d) = open_with_undo();
|
||||
rt().block_on(async {
|
||||
serial_pk_table(&db, "Students").await;
|
||||
serial_pk_table(&db, "Courses").await;
|
||||
// A real source makes the command undoable (a source-less call is
|
||||
// treated as an internal, non-undoable op).
|
||||
db.create_m2n_relationship(
|
||||
"Students".to_string(),
|
||||
"Courses".to_string(),
|
||||
None,
|
||||
Some("create m:n relationship from Students to Courses".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(db.list_tables().await.unwrap().contains(&"Students_Courses".to_string()));
|
||||
|
||||
// One undo removes the junction table AND both relationships.
|
||||
db.undo().await.unwrap();
|
||||
let tables = db.list_tables().await.unwrap();
|
||||
assert!(!tables.contains(&"Students_Courses".to_string()), "undo should remove the junction: {tables:?}");
|
||||
// The parents' relationships are gone too (the junction held them).
|
||||
let students = db.describe_table("Students".to_string(), None).await.unwrap();
|
||||
assert!(students.inbound_relationships.is_empty(), "no leftover relationship after undo");
|
||||
});
|
||||
}
|
||||
|
||||
// ---- guards ----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn self_referential_m2n_is_refused() {
|
||||
let (_p, db, _d) = open();
|
||||
rt().block_on(async {
|
||||
serial_pk_table(&db, "Users").await;
|
||||
let err = db
|
||||
.create_m2n_relationship("Users".to_string(), "Users".to_string(), None, None)
|
||||
.await
|
||||
.expect_err("self m:n must be refused");
|
||||
assert!(format!("{err}").contains("two different tables"), "got: {err}");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_parent_table_is_refused() {
|
||||
let (_p, db, _d) = open();
|
||||
rt().block_on(async {
|
||||
serial_pk_table(&db, "Students").await;
|
||||
let err = db
|
||||
.create_m2n_relationship("Students".to_string(), "Nonexistent".to_string(), None, None)
|
||||
.await
|
||||
.expect_err("a missing parent table must be refused");
|
||||
// The standard "no such table" guard (require_canonical_table).
|
||||
assert!(format!("{err}").to_lowercase().contains("no such table"), "got: {err}");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junction_name_collision_is_refused() {
|
||||
let (_p, db, _d) = open();
|
||||
rt().block_on(async {
|
||||
serial_pk_table(&db, "Students").await;
|
||||
serial_pk_table(&db, "Courses").await;
|
||||
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
|
||||
.await
|
||||
.expect("first m:n");
|
||||
// A second identical m:n collides on the auto-name `Students_Courses`.
|
||||
let err = db
|
||||
.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
|
||||
.await
|
||||
.expect_err("a junction-name collision must be refused");
|
||||
assert!(format!("{err}").to_lowercase().contains("exist"), "got: {err}");
|
||||
});
|
||||
}
|
||||
|
||||
// ---- the junction is a normal table ----------------------------
|
||||
|
||||
#[test]
|
||||
fn the_junction_can_be_renamed() {
|
||||
// C4 requirement text: "an auto-named junction table the user can
|
||||
// rename." It is a normal table, so `rename table` works.
|
||||
let (_p, db, _d) = open();
|
||||
rt().block_on(async {
|
||||
serial_pk_table(&db, "Students").await;
|
||||
serial_pk_table(&db, "Courses").await;
|
||||
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
db.rename_table("Students_Courses".to_string(), "Enrollments".to_string(), None)
|
||||
.await
|
||||
.expect("rename the junction");
|
||||
let tables = db.list_tables().await.unwrap();
|
||||
assert!(tables.contains(&"Enrollments".to_string()), "tables: {tables:?}");
|
||||
assert!(!tables.contains(&"Students_Courses".to_string()));
|
||||
// Both relationships survive the rename (rebuild-preserving).
|
||||
let desc = db.describe_table("Enrollments".to_string(), None).await.unwrap();
|
||||
assert_eq!(desc.outbound_relationships.len(), 2, "FKs preserved across rename");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junction_survives_save_and_rebuild() {
|
||||
// Persistence round-trip: the junction + both relationships are
|
||||
// reconstructed from project.yaml after the .db is discarded.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let project_path = {
|
||||
let project = project::open_or_create(None, Some(dir.path())).unwrap();
|
||||
let path = project.path().to_path_buf();
|
||||
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
serial_pk_table(&db, "Students").await;
|
||||
serial_pk_table(&db, "Courses").await;
|
||||
db.create_m2n_relationship(
|
||||
"Students".to_string(),
|
||||
"Courses".to_string(),
|
||||
None,
|
||||
Some("create m:n relationship from Students to Courses".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
drop(db);
|
||||
drop(project);
|
||||
path
|
||||
};
|
||||
// Discard the derived .db so the next open rebuilds from text.
|
||||
std::fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap();
|
||||
let project = project::Project::open(&project_path).unwrap();
|
||||
let db =
|
||||
Database::open_with_persistence(project.db_path(), Persistence::new(project.path().to_path_buf()))
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.rebuild_from_text(project.path().to_path_buf(), None).await.expect("rebuild");
|
||||
let tables = db.list_tables().await.unwrap();
|
||||
assert!(tables.contains(&"Students_Courses".to_string()), "junction survived: {tables:?}");
|
||||
let desc = db.describe_table("Students_Courses".to_string(), None).await.unwrap();
|
||||
assert_eq!(desc.outbound_relationships.len(), 2, "both FKs reconstructed");
|
||||
assert!(desc.columns.iter().all(|c| c.primary_key), "compound PK reconstructed");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn as_an_internal_name_is_refused() {
|
||||
// The junction must be a real, listable table — an `as __rdbms_*`
|
||||
// name would be filtered out of `list_tables` (a hidden orphan).
|
||||
// Guarded in the shared `do_create_table` (ADR-0045 /runda finding).
|
||||
let (_p, db, _d) = open();
|
||||
rt().block_on(async {
|
||||
serial_pk_table(&db, "Students").await;
|
||||
serial_pk_table(&db, "Courses").await;
|
||||
let err = db
|
||||
.create_m2n_relationship(
|
||||
"Students".to_string(),
|
||||
"Courses".to_string(),
|
||||
Some("__rdbms_evil".to_string()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect_err("an internal junction name must be refused");
|
||||
assert!(format!("{err}").contains("no such table"), "got: {err}");
|
||||
assert!(!db.list_tables().await.unwrap().contains(&"__rdbms_evil".to_string()));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pk_less_parent_is_refused() {
|
||||
let (_p, db, _d) = open();
|
||||
rt().block_on(async {
|
||||
serial_pk_table(&db, "Students").await;
|
||||
// A PK-less table via the advanced SQL path.
|
||||
db.sql_create_table(
|
||||
"Loose".to_string(),
|
||||
vec![ColumnSpec::new("a", Type::Int)],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let err = db
|
||||
.create_m2n_relationship("Students".to_string(), "Loose".to_string(), None, None)
|
||||
.await
|
||||
.expect_err("a PK-less parent must be refused");
|
||||
assert!(format!("{err}").contains("no primary key"), "got: {err}");
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Matrix coverage for `create m:n relationship from <T1> to <T2>
|
||||
//! [as <name>]` (C4 / ADR-0045). Exercises the full typing surface —
|
||||
//! completion candidates, ambient hint, highlighting, and parse state —
|
||||
//! at each stage, so a regression in any of those surfaces is caught.
|
||||
|
||||
use crate::typing_surface::*;
|
||||
use rdbms_playground::input_render::InputState;
|
||||
|
||||
#[test]
|
||||
fn after_create_offers_table_and_m2n() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end("create ", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
// `create` branches to `table` (create table) or the `m:n` composite.
|
||||
assert_candidate_present(&a, &["table", "m:n"]);
|
||||
crate::snap!("after_create", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m2n_relationship_keyword_sequence_is_incomplete() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end("create m:n relationship ", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
assert_candidate_present(&a, &["from"]);
|
||||
crate::snap!("after_relationship_keyword", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_from_offers_table_names() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end("create m:n relationship from ", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
assert_candidate_present(&a, &["Customers", "Orders"]);
|
||||
crate::snap!("after_from", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_to_offers_table_names() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end("create m:n relationship from Customers to ", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
assert_candidate_present(&a, &["Customers", "Orders"]);
|
||||
crate::snap!("after_to", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_create_m2n_parses() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end("create m:n relationship from Customers to Orders", &schema);
|
||||
assert!(matches!(a.state, InputState::Valid));
|
||||
assert_eq!(a.parse_result.as_deref(), Ok("CreateM2nRelationship"));
|
||||
crate::snap!("complete", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_m2n_with_as_name_parses() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end(
|
||||
"create m:n relationship from Customers to Orders as CustomerOrders",
|
||||
&schema,
|
||||
);
|
||||
assert!(matches!(a.state, InputState::Valid));
|
||||
assert_eq!(a.parse_result.as_deref(), Ok("CreateM2nRelationship"));
|
||||
crate::snap!("with_as_name", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_as_keyword_is_incomplete() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end("create m:n relationship from Customers to Orders as ", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
crate::snap!("after_as", a);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
+20
@@ -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)",
|
||||
),
|
||||
}
|
||||
+52
@@ -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)",
|
||||
),
|
||||
}
|
||||
+52
@@ -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)",
|
||||
),
|
||||
}
|
||||
+52
@@ -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)",
|
||||
),
|
||||
}
|
||||
+20
@@ -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",
|
||||
),
|
||||
}
|
||||
+20
@@ -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",
|
||||
),
|
||||
}
|
||||
+42
@@ -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)",
|
||||
),
|
||||
}
|
||||
+11
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
+3
-2
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user