Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,19 @@ _Avoid_: Import-time registration, partial registry
**Resolved registration**:
The registry epoch after relationship resolution completes — join tables exist, shadow FK columns are wired, and the SchemaIR modelset is authoritative for DDL and auto-migrate.
_Avoid_: Final registration, committed registry

**Create pass**:
The auto-migrate step that brings missing tables into existence — the table, its columns, its indexes and constraints, together. A table that already exists is left completely untouched by this pass, whatever its shape.
_Avoid_: Bootstrap, ensure-tables, table sync

**Reconciliation pass**:
The `migrate_updates` step that alters existing tables to match the registered models — the only authority for DDL against a table that already exists. Within one table, column changes land before the indexes and constraints that reference them.
_Avoid_: Update pass, schema sync, drift repair

**Ferro-owned artifact**:
An index or constraint whose name follows ferro's canonical naming (`idx_`, `uq_`, `fk_`, `ck_`), marking it as reconcilable: auto-migrate may create, rebuild, or drop it to match the declared model. Artifacts named any other way belong to the user and are never altered or dropped.
_Avoid_: Managed index, system constraint, internal index

**Constraint rebuild**:
Drop-and-recreate of a ferro-owned constraint whose live definition no longer matches the declared model — a foreign key's `on_delete`, its target, its columns. Metadata-only: rows are never touched. On a backend that cannot alter constraints, ferro warns loudly and skips; it never diverges silently.
_Avoid_: Constraint alter, FK patch, in-place constraint update
7 changes: 7 additions & 0 deletions crates/ferro-ddl-lowering/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,13 @@ pub fn fk_name(table_lower: &str, col_name: &str, to_table: &str) -> String {
raw
}

/// Whether a live constraint name follows the ferro FK convention above — the
/// ownership test: reconciliation only ever rebuilds names ferro emits;
/// constraints named any other way belong to the user and are never altered.
pub fn is_ferro_fk_name(name: &str) -> bool {
name.starts_with("fk_")
}

/// Single-column unique name with 63-char guard.
pub fn single_unique_index_name(table_lower: &str, col_name: &str) -> String {
let raw = format!("uq_{table_lower}_{col_name}");
Expand Down
99 changes: 89 additions & 10 deletions crates/ferro-migrate/src/emit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -408,16 +408,7 @@ fn emit_add_column(

if let Some(fk) = model.foreign_keys.iter().find(|fk| fk.column == column) {
if dialect == Dialect::Postgres {
let on_delete = fk_action_from_str(fk.on_delete.as_deref());
result.statements.push(format!(
"ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({}) ON DELETE {}",
quote_ident(table),
quote_ident(&fk_constraint_name(table, fk)),
quote_ident(column),
quote_ident(&fk.to_table),
quote_ident(&fk.to_column),
fk_action_sql(on_delete),
));
result.statements.push(render_add_fk_sql(table, fk));
} else {
result.warnings.push(format!(
"Added foreign-key column '{}.{}' without its FOREIGN KEY constraint \
Expand All @@ -432,6 +423,39 @@ fn emit_add_column(
Ok(result)
}

/// `ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY ... ON DELETE ...` for one
/// IR foreign key. Postgres-only — SQLite cannot add table constraints to an
/// existing table; its callers warn instead.
fn render_add_fk_sql(table: &str, fk: &ferro_schema_ir::SchemaForeignKey) -> String {
format!(
"ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({}) ON DELETE {}",
quote_ident(table),
quote_ident(&fk_constraint_name(table, fk)),
quote_ident(&fk.column),
quote_ident(&fk.to_table),
quote_ident(&fk.to_column),
fk_action_sql(fk_action_from_str(fk.on_delete.as_deref())),
)
}

/// Find the declared FK for `column` on `table` in the new-IR model.
fn find_foreign_key<'a>(
model: &'a SchemaModel,
table: &str,
column: &str,
) -> Result<&'a ferro_schema_ir::SchemaForeignKey, EmissionError> {
model
.foreign_keys
.iter()
.find(|fk| fk.column == column)
.ok_or_else(|| EmissionError {
message: format!(
"Foreign-key operation for '{}.{}' has no matching FK in the declared IR",
table, column
),
})
}

fn emit_alter_column_type(
table: &str,
column: &str,
Expand Down Expand Up @@ -673,6 +697,61 @@ pub fn emit_sql_with_ir(
MigrationOp::DropIndex { table: _, name } => {
result.statements.push(format!("DROP INDEX IF EXISTS \"{}\"", name));
}
MigrationOp::AddForeignKey { table, column } => {
let model = find_model(&new_models, table)?;
let fk = find_foreign_key(model, table, column)?;
match dialect {
Dialect::Postgres => result.statements.push(render_add_fk_sql(table, fk)),
Dialect::Sqlite => result.warnings.push(format!(
"Declared FOREIGN KEY on '{}.{}' (on_delete {}) has no live \
constraint, and SQLite cannot add table constraints to an \
existing table. Referential integrity for this column is not \
database-enforced; use Alembic if you need the constraint.",
table,
column,
fk_action_sql(fk_action_from_str(fk.on_delete.as_deref())),
)),
}
}
MigrationOp::RebuildForeignKey {
table,
column,
old_name,
} => {
let model = find_model(&new_models, table)?;
let fk = find_foreign_key(model, table, column)?;
match dialect {
Dialect::Postgres => {
result.statements.push(format!(
"ALTER TABLE {} DROP CONSTRAINT {}",
quote_ident(table),
quote_ident(old_name),
));
result.statements.push(render_add_fk_sql(table, fk));
}
Dialect::Sqlite => {
let live_action = find_model(&old_models, table)
.ok()
.and_then(|old| {
old.foreign_keys.iter().find(|live| live.column == *column)
})
.map(|live| {
fk_action_sql(fk_action_from_str(live.on_delete.as_deref()))
})
.unwrap_or("<unknown>");
result.warnings.push(format!(
"Foreign key on '{}.{}' declares on_delete {} but the live \
constraint enforces {}; SQLite cannot alter constraints in \
place, so the live behavior remains. Migrate with Alembic to \
apply the declared action.",
table,
column,
fk_action_sql(fk_action_from_str(fk.on_delete.as_deref())),
live_action,
));
}
}
}
}
}

Expand Down
104 changes: 103 additions & 1 deletion crates/ferro-migrate/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
mod emit;
mod order;

use ferro_ddl_lowering::schema_columns_storage_drift;
use ferro_ddl_lowering::{
fk_action_from_str, fk_action_sql, fk_name, is_ferro_fk_name, schema_columns_storage_drift,
};
use ferro_schema_ir::{IrEnvelope, SchemaIrPayload, SchemaModel};
use std::collections::{BTreeMap, BTreeSet};

Expand Down Expand Up @@ -97,6 +99,25 @@ pub enum MigrationOp {
/// Index name.
name: String,
},
/// A declared FK whose column exists live but has no FK constraint at all.
/// (FKs on newly added columns ride the `AddColumn` emission instead.)
AddForeignKey {
/// Owning table.
table: String,
/// Local FK column.
column: String,
},
/// A live ferro-owned FK whose definition (`on_delete`, target) drifted
/// from the declared FK on the same column — rebuilt as
/// `DROP CONSTRAINT` + `ADD CONSTRAINT` where the backend allows it.
RebuildForeignKey {
/// Owning table.
table: String,
/// Local FK column.
column: String,
/// Name of the live constraint to drop.
old_name: String,
},
}

/// Ordered migration operations plus non-fatal warnings collected during planning.
Expand Down Expand Up @@ -167,6 +188,13 @@ pub fn emit_sql(plan: &MigrationPlan, dialect: Dialect) -> Vec<String> {
MigrationOp::DropIndex { name, .. } => {
sql.push(format!("-- index '{}' handled by emit_sql_with_ir", name));
}
MigrationOp::AddForeignKey { table, column }
| MigrationOp::RebuildForeignKey { table, column, .. } => {
sql.push(format!(
"-- foreign key for '{}.{}' handled by emit_sql_with_ir",
table, column
));
}
}
}
sql
Expand Down Expand Up @@ -205,6 +233,7 @@ pub fn plan_from_ir(
};
diff_model_columns(*table, old_model, new_model, dialect, &mut plan);
diff_model_indexes(*table, old_model, new_model, &mut plan);
diff_model_foreign_keys(*table, old_model, new_model, &mut plan);
}

plan
Expand Down Expand Up @@ -320,5 +349,78 @@ fn diff_model_indexes(
}
}

fn diff_model_foreign_keys(
table: &str,
old_model: &SchemaModel,
new_model: &SchemaModel,
plan: &mut MigrationPlan,
) {
let old_col_names: BTreeSet<&str> = old_model.columns.iter().map(|c| c.name.as_str()).collect();

for fk in &new_model.foreign_keys {
// An FK on a newly added column rides the AddColumn emission; the
// reconcile step only governs FKs whose column already exists live.
if !old_col_names.contains(fk.column.as_str()) {
continue;
}

let Some(live) = old_model
.foreign_keys
.iter()
.find(|live| live.column == fk.column)
else {
plan.operations.push(MigrationOp::AddForeignKey {
table: table.to_string(),
column: fk.column.clone(),
});
continue;
};

// Live `to_column` can be empty when the backend reports an
// implicit-PK reference (SQLite); only a stated target can drift.
let target_drift = live.to_table != fk.to_table
|| (!live.to_column.is_empty() && live.to_column != fk.to_column);
// Compare via the canonical SQL rendering — sea-query's
// `ForeignKeyAction` has no equality of its own.
let action_drift = fk_action_sql(fk_action_from_str(live.on_delete.as_deref()))
!= fk_action_sql(fk_action_from_str(fk.on_delete.as_deref()));
if !target_drift && !action_drift {
continue;
}

match live.name.as_deref() {
// A drifting constraint ferro does not own is never altered —
// but it is never silent either.
Some(name) if !is_ferro_fk_name(name) => {
plan.warnings.push(format!(
"Foreign key on '{}.{}' drifts from the model (live: REFERENCES {} \
ON DELETE {}; declared: REFERENCES {} ON DELETE {}), but the live \
constraint '{}' is not ferro-owned, so it is left untouched. \
Migrate it manually or with Alembic.",
table,
fk.column,
live.to_table,
fk_action_sql(fk_action_from_str(live.on_delete.as_deref())),
fk.to_table,
fk_action_sql(fk_action_from_str(fk.on_delete.as_deref())),
name,
));
}
_ => {
plan.operations.push(MigrationOp::RebuildForeignKey {
table: table.to_string(),
column: fk.column.clone(),
// SQLite exposes no live constraint names; fall back to
// the canonical name (unused there — emission warns).
old_name: live
.name
.clone()
.unwrap_or_else(|| fk_name(table, &fk.column, &live.to_table)),
});
}
}
}
}

#[cfg(test)]
mod tests;
Loading
Loading