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:
+63
-8
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user