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
+73
View File
@@ -0,0 +1,73 @@
//! Matrix coverage for `create m:n relationship from <T1> to <T2>
//! [as <name>]` (C4 / ADR-0045). Exercises the full typing surface —
//! completion candidates, ambient hint, highlighting, and parse state —
//! at each stage, so a regression in any of those surfaces is caught.
use crate::typing_surface::*;
use rdbms_playground::input_render::InputState;
#[test]
fn after_create_offers_table_and_m2n() {
let schema = schema_multi_table();
let a = assess_at_end("create ", &schema);
assert!(matches!(a.state, InputState::IncompleteAtEof));
// `create` branches to `table` (create table) or the `m:n` composite.
assert_candidate_present(&a, &["table", "m:n"]);
crate::snap!("after_create", a);
}
#[test]
fn m2n_relationship_keyword_sequence_is_incomplete() {
let schema = schema_multi_table();
let a = assess_at_end("create m:n relationship ", &schema);
assert!(matches!(a.state, InputState::IncompleteAtEof));
assert_candidate_present(&a, &["from"]);
crate::snap!("after_relationship_keyword", a);
}
#[test]
fn after_from_offers_table_names() {
let schema = schema_multi_table();
let a = assess_at_end("create m:n relationship from ", &schema);
assert!(matches!(a.state, InputState::IncompleteAtEof));
assert_candidate_present(&a, &["Customers", "Orders"]);
crate::snap!("after_from", a);
}
#[test]
fn after_to_offers_table_names() {
let schema = schema_multi_table();
let a = assess_at_end("create m:n relationship from Customers to ", &schema);
assert!(matches!(a.state, InputState::IncompleteAtEof));
assert_candidate_present(&a, &["Customers", "Orders"]);
crate::snap!("after_to", a);
}
#[test]
fn complete_create_m2n_parses() {
let schema = schema_multi_table();
let a = assess_at_end("create m:n relationship from Customers to Orders", &schema);
assert!(matches!(a.state, InputState::Valid));
assert_eq!(a.parse_result.as_deref(), Ok("CreateM2nRelationship"));
crate::snap!("complete", a);
}
#[test]
fn create_m2n_with_as_name_parses() {
let schema = schema_multi_table();
let a = assess_at_end(
"create m:n relationship from Customers to Orders as CustomerOrders",
&schema,
);
assert!(matches!(a.state, InputState::Valid));
assert_eq!(a.parse_result.as_deref(), Ok("CreateM2nRelationship"));
crate::snap!("with_as_name", a);
}
#[test]
fn after_as_keyword_is_incomplete() {
let schema = schema_multi_table();
let a = assess_at_end("create m:n relationship from Customers to Orders as ", &schema);
assert!(matches!(a.state, InputState::IncompleteAtEof));
crate::snap!("after_as", a);
}
+2
View File
@@ -35,6 +35,7 @@ pub mod create_table;
pub mod drop_column;
pub mod drop_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(),
@@ -0,0 +1,20 @@
---
source: tests/typing_surface/create_m2n.rs
assertion_line: 72
description: "input=\"create m:n relationship from Customers to Orders as \" cursor=52"
expression: "& a"
---
Assessment {
input: "create m:n relationship from Customers to Orders as ",
cursor: 52,
state: IncompleteAtEof,
hint: Some(
Prose(
"Type a name",
),
),
completion: None,
parse_result: Err(
"Invalid(at_eof)",
),
}
@@ -0,0 +1,52 @@
---
source: tests/typing_surface/create_m2n.rs
assertion_line: 16
description: "input=\"create \" cursor=7"
expression: "& a"
---
Assessment {
input: "create ",
cursor: 7,
state: IncompleteAtEof,
hint: Some(
Candidates {
items: [
Candidate {
text: "table",
kind: Keyword,
mode: Simple,
},
Candidate {
text: "m:n",
kind: Keyword,
mode: Both,
},
],
selected: None,
},
),
completion: Some(
Completion {
replaced_range: (
7,
7,
),
partial_prefix: "",
candidates: [
Candidate {
text: "table",
kind: Keyword,
mode: Simple,
},
Candidate {
text: "m:n",
kind: Keyword,
mode: Both,
},
],
},
),
parse_result: Err(
"Invalid(at_eof)",
),
}
@@ -0,0 +1,52 @@
---
source: tests/typing_surface/create_m2n.rs
assertion_line: 34
description: "input=\"create m:n relationship from \" cursor=29"
expression: "& a"
---
Assessment {
input: "create m:n relationship from ",
cursor: 29,
state: IncompleteAtEof,
hint: Some(
Candidates {
items: [
Candidate {
text: "Customers",
kind: Identifier,
mode: Both,
},
Candidate {
text: "Orders",
kind: Identifier,
mode: Both,
},
],
selected: None,
},
),
completion: Some(
Completion {
replaced_range: (
29,
29,
),
partial_prefix: "",
candidates: [
Candidate {
text: "Customers",
kind: Identifier,
mode: Both,
},
Candidate {
text: "Orders",
kind: Identifier,
mode: Both,
},
],
},
),
parse_result: Err(
"Invalid(at_eof)",
),
}
@@ -0,0 +1,52 @@
---
source: tests/typing_surface/create_m2n.rs
assertion_line: 43
description: "input=\"create m:n relationship from Customers to \" cursor=42"
expression: "& a"
---
Assessment {
input: "create m:n relationship from Customers to ",
cursor: 42,
state: IncompleteAtEof,
hint: Some(
Candidates {
items: [
Candidate {
text: "Customers",
kind: Identifier,
mode: Both,
},
Candidate {
text: "Orders",
kind: Identifier,
mode: Both,
},
],
selected: None,
},
),
completion: Some(
Completion {
replaced_range: (
42,
42,
),
partial_prefix: "",
candidates: [
Candidate {
text: "Customers",
kind: Identifier,
mode: Both,
},
Candidate {
text: "Orders",
kind: Identifier,
mode: Both,
},
],
},
),
parse_result: Err(
"Invalid(at_eof)",
),
}
@@ -0,0 +1,20 @@
---
source: tests/typing_surface/create_m2n.rs
assertion_line: 52
description: "input=\"create m:n relationship from Customers to Orders\" cursor=48"
expression: "& a"
---
Assessment {
input: "create m:n relationship from Customers to Orders",
cursor: 48,
state: Valid,
hint: Some(
Prose(
"Submit with Enter",
),
),
completion: None,
parse_result: Ok(
"CreateM2nRelationship",
),
}
@@ -0,0 +1,20 @@
---
source: tests/typing_surface/create_m2n.rs
assertion_line: 64
description: "input=\"create m:n relationship from Customers to Orders as CustomerOrders\" cursor=66"
expression: "& a"
---
Assessment {
input: "create m:n relationship from Customers to Orders as CustomerOrders",
cursor: 66,
state: Valid,
hint: Some(
Prose(
"Type a name",
),
),
completion: None,
parse_result: Ok(
"CreateM2nRelationship",
),
}
@@ -0,0 +1,42 @@
---
source: tests/typing_surface/create_m2n.rs
assertion_line: 25
description: "input=\"create m:n relationship \" cursor=24"
expression: "& a"
---
Assessment {
input: "create m:n relationship ",
cursor: 24,
state: IncompleteAtEof,
hint: Some(
Candidates {
items: [
Candidate {
text: "from",
kind: Keyword,
mode: Simple,
},
],
selected: None,
},
),
completion: Some(
Completion {
replaced_range: (
24,
24,
),
partial_prefix: "",
candidates: [
Candidate {
text: "from",
kind: Keyword,
mode: Simple,
},
],
},
),
parse_result: Err(
"Invalid(at_eof)",
),
}
@@ -1,5 +1,6 @@
---
source: tests/typing_surface/create_table.rs
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,
},
],
@@ -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,
},
],
},