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:
claude@clouddev1
2026-05-25 15:35:48 +00:00
parent 60111f69d5
commit 76d60591bf
11 changed files with 1588 additions and 81 deletions
+283 -49
View File
@@ -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).