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
+171
View File
@@ -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();