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
+16
View File
@@ -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
+69
View File
@@ -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 {
+14
View File
@@ -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!(
+15
View File
@@ -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.
+63 -8
View File
@@ -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<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,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.