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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user