feat: ADR-0035 4b — foreign keys in CREATE TABLE
Add foreign keys to advanced-mode SQL CREATE TABLE — the SQL spelling of an ADR-0013 named relationship, created in the same transaction as the table (one undo step). - Grammar: inline `<col> … REFERENCES <parent>[(<col>)] [ON DELETE/UPDATE …]` (a new column constraint) and table-level `[CONSTRAINT <name>] FOREIGN KEY (<col>) REFERENCES …` (two new element branches — both start on a concrete keyword, never a leading Optional, which would abort the element Choice). Referential clauses reuse shared::REFERENTIAL_CLAUSES. - Builder: greedy FK-clause consumption (parens consumed internally so they don't perturb the 4a.3 element-boundary depth tracker); inline FK auto-named, table FK takes an optional CONSTRAINT name. - Worker: do_create_table resolves + validates each FK before building the DDL (self-ref validates against the in-statement columns/PK; bare REFERENCES resolves to the parent's single-column PK, composite -> error; PK-target + Type::fk_target_type compatibility), emits the FOREIGN KEY clause identically to schema_to_ddl, and writes the relationship metadata in the create transaction. - Reuse: name/uniqueness/metadata-insert/type-compat factored into shared helpers; do_add_relationship refactored to use them. - FKs round-trip via the existing relationship plumbing (no new persistence structures); describe surfaces the relationship. Self-references and bare `REFERENCES <parent>` supported (user-confirmed). Self-ref pre-submit indicator wrinkle deferred to 4i (tracked in ADR §13, a code comment, and the plan). DA/runda round added cross-cutting probes (FK survives the add-column rebuild + a later rebuild_from_text; referential actions survive rebuild; drop-child clears the relationship; drop-parent refused; bare self-ref resolves to own PK) — all green, no fixes needed. 27 new tests (grammar/builder + Tier-3). Docs: ADR-0035 Status/§13, README, requirements.md Q1. Tests: 1795 passing, 0 failing, 1 ignored. Clippy clean.
This commit is contained in:
@@ -33,7 +33,7 @@ use tracing::{debug, info, warn};
|
||||
use crate::dsl::action::ReferentialAction;
|
||||
use crate::dsl::command::{
|
||||
ChangeColumnMode, Command, CompareOp, Constraint, ConstraintKind, Expr, IndexSelector,
|
||||
Operand, Predicate, RelationshipSelector, RowFilter,
|
||||
Operand, Predicate, RelationshipSelector, RowFilter, SqlForeignKey,
|
||||
};
|
||||
use crate::dsl::ColumnSpec;
|
||||
use crate::dsl::shortid;
|
||||
@@ -467,6 +467,7 @@ enum Request {
|
||||
primary_key: Vec<String>,
|
||||
unique_constraints: Vec<Vec<String>>,
|
||||
check_constraints: Vec<String>,
|
||||
foreign_keys: Vec<SqlForeignKey>,
|
||||
if_not_exists: bool,
|
||||
source: Option<String>,
|
||||
reply: oneshot::Sender<Result<CreateOutcome, DbError>>,
|
||||
@@ -838,6 +839,7 @@ impl Database {
|
||||
primary_key: Vec<String>,
|
||||
unique_constraints: Vec<Vec<String>>,
|
||||
check_constraints: Vec<String>,
|
||||
foreign_keys: Vec<SqlForeignKey>,
|
||||
if_not_exists: bool,
|
||||
source: Option<String>,
|
||||
) -> Result<CreateOutcome, DbError> {
|
||||
@@ -848,6 +850,7 @@ impl Database {
|
||||
primary_key,
|
||||
unique_constraints,
|
||||
check_constraints,
|
||||
foreign_keys,
|
||||
if_not_exists,
|
||||
source,
|
||||
reply,
|
||||
@@ -1708,6 +1711,7 @@ fn handle_request(
|
||||
&primary_key,
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
));
|
||||
}
|
||||
Request::SqlCreateTable {
|
||||
@@ -1716,6 +1720,7 @@ fn handle_request(
|
||||
primary_key,
|
||||
unique_constraints,
|
||||
check_constraints,
|
||||
foreign_keys,
|
||||
if_not_exists,
|
||||
source,
|
||||
reply,
|
||||
@@ -1745,6 +1750,7 @@ fn handle_request(
|
||||
&primary_key,
|
||||
&unique_constraints,
|
||||
&check_constraints,
|
||||
&foreign_keys,
|
||||
)
|
||||
.map(CreateOutcome::Created)
|
||||
});
|
||||
@@ -2637,6 +2643,7 @@ fn do_create_table(
|
||||
primary_key: &[String],
|
||||
unique_constraints: &[Vec<String>],
|
||||
check_constraints: &[String],
|
||||
foreign_keys: &[SqlForeignKey],
|
||||
) -> Result<TableDescription, DbError> {
|
||||
if columns.is_empty() {
|
||||
// SQLite requires at least one column. The DSL grammar
|
||||
@@ -2647,6 +2654,11 @@ fn do_create_table(
|
||||
"tables need at least one column".to_string(),
|
||||
));
|
||||
}
|
||||
// Resolve + validate any foreign keys before building the DDL, so
|
||||
// an invalid reference aborts before the table is created (ADR-0035
|
||||
// §5, sub-phase 4b). Self-references validate against the columns
|
||||
// being defined; other parents must already exist.
|
||||
let resolved_fks = resolve_create_table_fks(conn, name, columns, primary_key, foreign_keys)?;
|
||||
|
||||
// Inline `PRIMARY KEY` on the column when the table has a single
|
||||
// primary-key column and it is the **first** column — the exact
|
||||
@@ -2736,6 +2748,21 @@ fn do_create_table(
|
||||
ddl.push(')');
|
||||
}
|
||||
|
||||
// Foreign keys (ADR-0035 §5, sub-phase 4b) — emitted identically to
|
||||
// `schema_to_ddl` (the §6.1 two-generators rule): always the
|
||||
// explicit resolved parent column + both actions, so the create DDL
|
||||
// and the rebuild DDL match byte-for-byte.
|
||||
for fk in &resolved_fks {
|
||||
ddl.push_str(&format!(
|
||||
", FOREIGN KEY ({child}) REFERENCES {parent}({pcol}) ON DELETE {od} ON UPDATE {ou}",
|
||||
child = quote_ident(&fk.child_column),
|
||||
parent = quote_ident(&fk.parent_table),
|
||||
pcol = quote_ident(&fk.parent_column),
|
||||
od = fk.on_delete.sql_clause(),
|
||||
ou = fk.on_update.sql_clause(),
|
||||
));
|
||||
}
|
||||
|
||||
ddl.push_str(") STRICT;");
|
||||
debug!(ddl = %ddl, "create_table");
|
||||
|
||||
@@ -2761,6 +2788,21 @@ fn do_create_table(
|
||||
)
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
}
|
||||
// Foreign-key relationships (ADR-0035 §5): one metadata row per FK,
|
||||
// in the same transaction as the table — so the whole statement is
|
||||
// one undo step.
|
||||
for fk in &resolved_fks {
|
||||
insert_relationship_metadata(
|
||||
&tx,
|
||||
&fk.name,
|
||||
&fk.parent_table,
|
||||
&fk.parent_column,
|
||||
name,
|
||||
&fk.child_column,
|
||||
fk.on_delete,
|
||||
fk.on_update,
|
||||
)?;
|
||||
}
|
||||
let description = do_describe_table(conn, name)?;
|
||||
let changes = Changes {
|
||||
schema_dirty: true,
|
||||
@@ -5314,6 +5356,225 @@ where
|
||||
)
|
||||
}
|
||||
|
||||
/// Auto-name a relationship per ADR-0013
|
||||
/// (`<Parent>_<pcol>_to_<Child>_<ccol>`, reading in the declared
|
||||
/// direction) when `name` is `None`; otherwise use the supplied name.
|
||||
/// Shared by the DSL `add relationship` path and advanced-mode SQL
|
||||
/// `CREATE TABLE` foreign keys (ADR-0035 §5).
|
||||
fn resolve_relationship_name(
|
||||
name: Option<&str>,
|
||||
parent_table: &str,
|
||||
parent_column: &str,
|
||||
child_table: &str,
|
||||
child_column: &str,
|
||||
) -> String {
|
||||
name.map_or_else(
|
||||
|| format!("{parent_table}_{parent_column}_to_{child_table}_{child_column}"),
|
||||
ToString::to_string,
|
||||
)
|
||||
}
|
||||
|
||||
/// Reject a relationship name that collides with an existing one (the
|
||||
/// `name` column is UNIQUE, ADR-0013). Engine-neutral message.
|
||||
fn ensure_relationship_name_unique(conn: &Connection, name: &str) -> Result<(), DbError> {
|
||||
let collision: i64 = conn
|
||||
.query_row(
|
||||
&format!("SELECT COUNT(*) FROM {REL_TABLE} WHERE name = ?1;"),
|
||||
[name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
if collision > 0 {
|
||||
return Err(DbError::Unsupported(format!(
|
||||
"a relationship named `{name}` already exists. \
|
||||
Pick a different name or drop the existing one first."
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert one relationship metadata row into `REL_TABLE` (ADR-0013).
|
||||
/// Shared by `add relationship` (inside its rebuild transaction) and
|
||||
/// SQL `CREATE TABLE` foreign keys (inside the create transaction);
|
||||
/// `conn` may be a `&Transaction` (deref-coerces to `&Connection`).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn insert_relationship_metadata(
|
||||
conn: &Connection,
|
||||
name: &str,
|
||||
parent_table: &str,
|
||||
parent_column: &str,
|
||||
child_table: &str,
|
||||
child_column: &str,
|
||||
on_delete: ReferentialAction,
|
||||
on_update: ReferentialAction,
|
||||
) -> Result<(), DbError> {
|
||||
conn.execute(
|
||||
&format!(
|
||||
"INSERT INTO {REL_TABLE} \
|
||||
(name, parent_table, parent_column, child_table, child_column, on_delete, on_update) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);"
|
||||
),
|
||||
[
|
||||
name,
|
||||
parent_table,
|
||||
parent_column,
|
||||
child_table,
|
||||
child_column,
|
||||
on_delete.keyword(),
|
||||
on_update.keyword(),
|
||||
],
|
||||
)
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate that an FK child column's type is compatible with the
|
||||
/// referenced parent column's type — it must equal the parent type's
|
||||
/// `fk_target_type()` (ADR-0011). Engine-neutral mismatch error.
|
||||
fn check_fk_type_compat(
|
||||
parent_table: &str,
|
||||
parent_column: &str,
|
||||
parent_type: Type,
|
||||
child_table: &str,
|
||||
child_column: &str,
|
||||
child_type: Type,
|
||||
) -> Result<(), DbError> {
|
||||
let expected = parent_type.fk_target_type();
|
||||
if child_type != expected {
|
||||
return Err(DbError::Unsupported(format!(
|
||||
"type mismatch: `{child_table}.{child_column}` is `{child_type}` but \
|
||||
a foreign key referencing `{parent_table}.{parent_column}` \
|
||||
(`{parent_type}`) requires `{expected}`. \
|
||||
Either change the column type, or pick a different FK column."
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A `CREATE TABLE` foreign key after resolution + validation
|
||||
/// (ADR-0035 §5, sub-phase 4b): the bare-`REFERENCES` parent column is
|
||||
/// resolved, the relationship name is decided, and PK-target /
|
||||
/// type-compat are checked.
|
||||
struct ResolvedFk {
|
||||
name: String,
|
||||
child_column: String,
|
||||
parent_table: String,
|
||||
parent_column: String,
|
||||
on_delete: ReferentialAction,
|
||||
on_update: ReferentialAction,
|
||||
}
|
||||
|
||||
/// Resolve + validate every foreign key declared in a `CREATE TABLE`
|
||||
/// (ADR-0035 §5, sub-phase 4b) **before** the table is built, so an
|
||||
/// invalid reference aborts cleanly. A self-referencing FK (parent is
|
||||
/// the table being created) is validated against the columns/PK being
|
||||
/// defined; any other parent must already exist. The bare
|
||||
/// `REFERENCES <parent>` form resolves to the parent's single-column
|
||||
/// PK (composite → error). Reuses the relationship validation/naming
|
||||
/// helpers shared with `do_add_relationship`.
|
||||
fn resolve_create_table_fks(
|
||||
conn: &Connection,
|
||||
table_name: &str,
|
||||
columns: &[ColumnSpec],
|
||||
primary_key: &[String],
|
||||
foreign_keys: &[SqlForeignKey],
|
||||
) -> Result<Vec<ResolvedFk>, DbError> {
|
||||
let mut out = Vec::with_capacity(foreign_keys.len());
|
||||
for fk in foreign_keys {
|
||||
// The parent's PK column list + a (name -> user type) lookup.
|
||||
// A self-reference reads the in-statement specs (the table does
|
||||
// not exist yet); any other parent must already exist.
|
||||
let (parent_pk, parent_cols): (Vec<String>, Vec<(String, Option<Type>)>) =
|
||||
if fk.parent_table == table_name {
|
||||
(
|
||||
primary_key.to_vec(),
|
||||
columns.iter().map(|c| (c.name.clone(), Some(c.ty))).collect(),
|
||||
)
|
||||
} else {
|
||||
let ps = read_schema(conn, &fk.parent_table)?;
|
||||
(
|
||||
ps.primary_key.clone(),
|
||||
ps.columns
|
||||
.iter()
|
||||
.map(|c| (c.name.clone(), c.user_type))
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
|
||||
// Explicit referenced column, or the parent's single-column PK
|
||||
// for the bare `REFERENCES <parent>` form.
|
||||
let parent_column = match &fk.parent_column {
|
||||
Some(c) => c.clone(),
|
||||
None => {
|
||||
if parent_pk.len() == 1 {
|
||||
parent_pk[0].clone()
|
||||
} else {
|
||||
return Err(DbError::Unsupported(format!(
|
||||
"`{parent}` has a composite primary key, so a bare \
|
||||
reference is ambiguous — name the referenced column, \
|
||||
e.g. `REFERENCES {parent}(<col>)`.",
|
||||
parent = fk.parent_table,
|
||||
)));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// The referenced column must be a primary key (ADR-0011/0013).
|
||||
if !parent_pk.contains(&parent_column) {
|
||||
return Err(DbError::Unsupported(format!(
|
||||
"column `{}.{}` is not a primary key. Foreign keys must \
|
||||
reference a primary key (UNIQUE-target FKs land in a later \
|
||||
iteration).",
|
||||
fk.parent_table, parent_column
|
||||
)));
|
||||
}
|
||||
let parent_type = parent_cols
|
||||
.iter()
|
||||
.find(|(n, _)| n == &parent_column)
|
||||
.and_then(|(_, t)| *t)
|
||||
.ok_or_else(|| DbError::Sqlite {
|
||||
message: format!("no such column: {}.{}", fk.parent_table, parent_column),
|
||||
kind: SqliteErrorKind::NoSuchColumn,
|
||||
})?;
|
||||
|
||||
// The child column must be one of the columns being defined.
|
||||
let child = columns
|
||||
.iter()
|
||||
.find(|c| c.name == fk.child_column)
|
||||
.ok_or_else(|| DbError::Sqlite {
|
||||
message: format!("no such column: {}.{}", table_name, fk.child_column),
|
||||
kind: SqliteErrorKind::NoSuchColumn,
|
||||
})?;
|
||||
check_fk_type_compat(
|
||||
&fk.parent_table,
|
||||
&parent_column,
|
||||
parent_type,
|
||||
table_name,
|
||||
&fk.child_column,
|
||||
child.ty,
|
||||
)?;
|
||||
|
||||
let resolved_name = resolve_relationship_name(
|
||||
fk.name.as_deref(),
|
||||
&fk.parent_table,
|
||||
&parent_column,
|
||||
table_name,
|
||||
&fk.child_column,
|
||||
);
|
||||
ensure_relationship_name_unique(conn, &resolved_name)?;
|
||||
|
||||
out.push(ResolvedFk {
|
||||
name: resolved_name,
|
||||
child_column: fk.child_column.clone(),
|
||||
parent_table: fk.parent_table.clone(),
|
||||
parent_column,
|
||||
on_delete: fk.on_delete,
|
||||
on_update: fk.on_update,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn do_add_relationship(
|
||||
conn: &Connection,
|
||||
@@ -5388,38 +5649,21 @@ fn do_add_relationship(
|
||||
let actual = child_col.user_type.ok_or_else(|| DbError::Unsupported(
|
||||
"child column has no user type metadata".to_string(),
|
||||
))?;
|
||||
if actual != expected_child_type {
|
||||
return Err(DbError::Unsupported(format!(
|
||||
"type mismatch: `{child_table}.{child_column}` is `{actual}` but \
|
||||
a foreign key referencing `{parent_table}.{parent_column}` \
|
||||
(`{parent_user_type}`) requires `{expected_child_type}`. \
|
||||
Either change the column type, or pick a different FK column."
|
||||
)));
|
||||
}
|
||||
check_fk_type_compat(
|
||||
parent_table,
|
||||
parent_column,
|
||||
parent_user_type,
|
||||
child_table,
|
||||
child_column,
|
||||
actual,
|
||||
)?;
|
||||
}
|
||||
|
||||
// 4. Determine relationship name (auto-gen or supplied) and
|
||||
// check uniqueness against the metadata table.
|
||||
let resolved_name = name.map_or_else(
|
||||
// Auto-name follows the user-typed `from <Parent>.<col>
|
||||
// to <Child>.<col>` direction so the name reads as the
|
||||
// grammar reads — see ADR-0013.
|
||||
|| format!("{parent_table}_{parent_column}_to_{child_table}_{child_column}"),
|
||||
ToString::to_string,
|
||||
);
|
||||
let collision: i64 = conn
|
||||
.query_row(
|
||||
&format!("SELECT COUNT(*) FROM {REL_TABLE} WHERE name = ?1;"),
|
||||
[&resolved_name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
if collision > 0 {
|
||||
return Err(DbError::Unsupported(format!(
|
||||
"a relationship named `{resolved_name}` already exists. \
|
||||
Pick a different name or drop the existing one first."
|
||||
)));
|
||||
}
|
||||
let resolved_name =
|
||||
resolve_relationship_name(name, parent_table, parent_column, child_table, child_column);
|
||||
ensure_relationship_name_unique(conn, &resolved_name)?;
|
||||
|
||||
// 5. Build the new schema with the FK appended.
|
||||
let mut new_schema = child_schema.clone();
|
||||
@@ -5432,10 +5676,7 @@ fn do_add_relationship(
|
||||
});
|
||||
|
||||
// 6. Rebuild, with metadata updates inside the transaction.
|
||||
let on_delete_kw = on_delete.keyword();
|
||||
let on_update_kw = on_update.keyword();
|
||||
let column_user_type_kw = expected_child_type.keyword();
|
||||
let resolved_name_for_meta = resolved_name.as_str();
|
||||
rebuild_table(conn, child_table, &child_schema, &new_schema, |tx| {
|
||||
if needs_create_column {
|
||||
tx.execute(
|
||||
@@ -5447,23 +5688,16 @@ fn do_add_relationship(
|
||||
)
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
}
|
||||
tx.execute(
|
||||
&format!(
|
||||
"INSERT INTO {REL_TABLE} \
|
||||
(name, parent_table, parent_column, child_table, child_column, on_delete, on_update) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);"
|
||||
),
|
||||
[
|
||||
resolved_name_for_meta,
|
||||
parent_table,
|
||||
parent_column,
|
||||
child_table,
|
||||
child_column,
|
||||
on_delete_kw,
|
||||
on_update_kw,
|
||||
],
|
||||
)
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
insert_relationship_metadata(
|
||||
tx,
|
||||
&resolved_name,
|
||||
parent_table,
|
||||
parent_column,
|
||||
child_table,
|
||||
child_column,
|
||||
on_delete,
|
||||
on_update,
|
||||
)?;
|
||||
// Persistence runs inside the same tx so a write
|
||||
// failure rolls back both the schema and the metadata
|
||||
// (commit-db-last per ADR-0015 §6).
|
||||
|
||||
Reference in New Issue
Block a user