feat: create m:n relationship convenience command (C4, ADR-0045)

`create m:n relationship from <T1> to <T2> [as <name>]` generates a
junction table with one FK column per parent PK column ({table}_{pkcol},
typed via fk_target_type), a compound PK over them, and two CASCADE 1:n
relationships -- all in one do_create_table call = one undo step.
Auto-named {T1}_{T2} (optional `as`), both modes, compound-parent PKs
supported (ADR-0043). Self-referential m:n / PK-less parent / internal
junction name / name collision all refused.

Wired across every surface: grammar (separate CREATE_M2N node), worker
executor, runtime dispatch, completion ("m:n" composite), hints,
highlighting, help + usage catalog + disambiguator, and the advanced-mode
DSL->SQL teaching echo (render_create_m2n, round-trips as valid SQL).

Generalized/fixed framework assumptions the build + two /runda passes
surfaced (all behaviour-preserving for existing commands):
- simple-mode dispatch committed simple.first() unconditionally -> tries
  candidates, so `create table` no longer shadows `create m:n`.
- the completion continuation-merge was advanced-only -> runs in simple
  mode too when an entry word has >1 DSL form (gated simple_count>1).
- do_create_table now rejects internal `__rdbms_*` names (closes a
  pre-existing hole on the DSL create-table path too, not just m:n).
- usage disambiguator now recognizes the `m:n` opener.

Tests: 14 integration (tests/it/m2n.rs), 7 typing-surface matrix, echo /
highlight / usage / internal-name units. Closes C4.
2237 pass / 0 fail / 1 ignored. Clippy clean.
This commit is contained in:
claude@clouddev1
2026-06-10 14:26:33 +00:00
parent e598008ecf
commit 8bd43ccadf
28 changed files with 1273 additions and 26 deletions
+18 -10
View File
@@ -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());
}