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
26 changes: 23 additions & 3 deletions internal/diff/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,15 @@ type ddlDiff struct {
// exist when the view body is parsed (issue #414).
deferredAddedViews []*ir.View
functionsAwaitingDeferredViews []*ir.Function
// Foreign keys that depend on a unique/PK constraint being dropped or
// recreated by this migration: existing ones are dropped before the table
// modifications (fkPreDrops) and desired-state ones are (re)created
// afterwards (fkPostAdds). FKs on newly added tables that would bind to the
// old constraint are kept out of CREATE TABLE (suppressedInlineFKs) and
// created via fkPostAdds instead (issue #439).
fkPreDrops []*ir.Constraint
fkPostAdds []*deferredConstraint
suppressedInlineFKs map[string]bool
}

// schemaDiff represents changes to a schema
Expand Down Expand Up @@ -1459,6 +1468,10 @@ func GenerateMigration(oldIR, newIR *ir.IR, targetSchema string) []Diff {
// Sort individual table objects (indexes, triggers, policies, constraints) within each table
sortTableObjects(diff.modifiedTables)

// Detect foreign keys bound to unique/PK constraints being replaced; they
// must be dropped before and recreated after the replacement (issue #439)
diff.fkPreDrops, diff.fkPostAdds, diff.suppressedInlineFKs = planFKRecreationForReplacedConstraints(diff.modifiedTables, diff.addedTables, oldTables, newTables)

// Create a diffCollector and generate SQL
collector := newDiffCollector()
diff.collectMigrationSQL(targetSchema, collector)
Expand Down Expand Up @@ -1772,7 +1785,7 @@ func (d *ddlDiff) generateCreateSQL(targetSchema string, collector *diffCollecto
}

// Create tables WITHOUT function/domain dependencies first (functions may reference these)
deferredPolicies1, deferredConstraints1 := generateCreateTablesSQL(tablesWithoutDeps, targetSchema, collector, existingTables, shouldDeferPolicy)
deferredPolicies1, deferredConstraints1 := generateCreateTablesSQL(tablesWithoutDeps, targetSchema, collector, existingTables, shouldDeferPolicy, d.suppressedInlineFKs)

// Build view lookup - needed for detecting functions that depend on views
newViewLookup := buildViewLookup(d.addedViews)
Expand Down Expand Up @@ -1803,7 +1816,7 @@ func (d *ddlDiff) generateCreateSQL(targetSchema string, collector *diffCollecto
generateCreateProceduresSQL(d.addedProcedures, targetSchema, collector)

// Create tables WITH function/domain dependencies (now that functions and deferred domains exist)
deferredPolicies2, deferredConstraints2 := generateCreateTablesSQL(tablesWithDeps, targetSchema, collector, existingTables, shouldDeferPolicy)
deferredPolicies2, deferredConstraints2 := generateCreateTablesSQL(tablesWithDeps, targetSchema, collector, existingTables, shouldDeferPolicy, d.suppressedInlineFKs)

// Add deferred foreign key constraints from BOTH batches AFTER all tables are created
// This ensures FK references to tables in the second batch (function-dependent tables) work correctly
Expand Down Expand Up @@ -1912,8 +1925,15 @@ func (d *ddlDiff) generateModifySQL(targetSchema string, collector *diffCollecto
// Modify sequences
generateModifySequencesSQL(d.modifiedSequences, targetSchema, collector)

// Drop foreign keys bound to unique/PK constraints being replaced, so the
// constraint drops below succeed; they are recreated right after (issue #439)
generateDropRecreatedFKsSQL(d.fkPreDrops, targetSchema, collector)

// Modify tables
generateModifyTablesSQL(d.modifiedTables, d.droppedTables, targetSchema, collector)
generateModifyTablesSQL(d.modifiedTables, d.droppedTables, d.fkPreDrops, targetSchema, collector)

// (Re)create the dependent foreign keys now that the replacement constraints exist
generateDeferredConstraintsSQL(d.fkPostAdds, targetSchema, collector)

// Create views deferred from generateCreateSQL — their bodies reference
// columns just added by ALTER TABLE above (issue #414). Likewise, emit
Expand Down
193 changes: 188 additions & 5 deletions internal/diff/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,7 @@ func generateCreateTablesSQL(
collector *diffCollector,
existingTables map[string]bool,
shouldDeferPolicy func(*ir.RLSPolicy) bool,
suppressedInlineFKs map[string]bool,
) ([]*ir.RLSPolicy, []*deferredConstraint) {
var deferredPolicies []*ir.RLSPolicy
var deferredConstraints []*deferredConstraint
Expand All @@ -408,7 +409,7 @@ func generateCreateTablesSQL(
// Process tables in the provided order (already topologically sorted)
for _, table := range tables {
// Create the table, deferring FK constraints that reference not-yet-created tables
sql, tableDeferred := generateTableSQL(table, targetSchema, createdTables, existingTables)
sql, tableDeferred := generateTableSQL(table, targetSchema, createdTables, existingTables, suppressedInlineFKs)
deferredConstraints = append(deferredConstraints, tableDeferred...)

// Create context for this statement
Expand Down Expand Up @@ -541,13 +542,20 @@ func generateDeferredConstraintsSQL(deferred []*deferredConstraint, targetSchema
}

// generateModifyTablesSQL generates ALTER TABLE statements
func generateModifyTablesSQL(diffs []*tableDiff, droppedTables []*ir.Table, targetSchema string, collector *diffCollector) {
func generateModifyTablesSQL(diffs []*tableDiff, droppedTables []*ir.Table, fkPreDrops []*ir.Constraint, targetSchema string, collector *diffCollector) {
// Build a set of tables being dropped (CASCADE will remove their dependent FK constraints)
droppedTableSet := make(map[string]bool, len(droppedTables))
for _, t := range droppedTables {
droppedTableSet[t.Schema+"."+t.Name] = true
}

// Build a set of FKs already dropped in the pre-drop step because they were
// bound to a unique/PK constraint being replaced (#439)
preDroppedFKSet := make(map[string]bool, len(fkPreDrops))
for _, fk := range fkPreDrops {
preDroppedFKSet[constraintPathKey(fk)] = true
}

// Diffs are already sorted by the Diff operation
for _, diff := range diffs {
// Build a set of columns being dropped (DROP COLUMN will remove dependent constraints)
Expand All @@ -557,7 +565,163 @@ func generateModifyTablesSQL(diffs []*tableDiff, droppedTables []*ir.Table, targ
}

// Pass collector to generateAlterTableStatements to collect with proper context
diff.generateAlterTableStatements(targetSchema, collector, droppedTableSet, droppedColumnSet)
diff.generateAlterTableStatements(targetSchema, collector, droppedTableSet, droppedColumnSet, preDroppedFKSet)
}
}

// planFKRecreationForReplacedConstraints handles foreign keys that depend on a
// unique/primary-key constraint this migration drops or recreates. Postgres
// ties a foreign key to the specific unique constraint it was created against,
// so the constraint cannot be dropped while the FK exists (SQLSTATE 2BP01),
// and an FK created while the old constraint still exists binds to it. (#439)
//
// It returns:
// - preDrops: old-state FKs bound to a replaced constraint, to drop before
// the table modifications. The drop/modify entries they cover are skipped
// at emission time (see generateAlterTableStatements) so they are not
// dropped twice.
// - postAdds: desired-state FKs to (re)create after the table modifications,
// once the replacement constraint exists.
// - suppressedInlineFKs: FKs on newly added tables (keyed by
// schema.table.constraint) that must be left out of CREATE TABLE — emitted
// inline they would bind to the old constraint before the replacement runs.
// They are recreated via postAdds instead.
//
// Note: Postgres matches an FK's referenced columns to a unique constraint as
// a column set, not an ordered list — FOREIGN KEY (x, y) REFERENCES t (b, a)
// is valid against UNIQUE (a, b) — so matching here is order-insensitive.
func planFKRecreationForReplacedConstraints(modifiedTables []*tableDiff, addedTables []*ir.Table, oldTables, newTables map[string]*ir.Table) (preDrops []*ir.Constraint, postAdds []*deferredConstraint, suppressedInlineFKs map[string]bool) {
// Unique/PK constraints removed by this migration, keyed by their table
replaced := make(map[string][]*ir.Constraint)
for _, td := range modifiedTables {
key := td.Table.Schema + "." + td.Table.Name
for _, c := range td.DroppedConstraints {
if c.Type == ir.ConstraintTypeUnique || c.Type == ir.ConstraintTypePrimaryKey {
replaced[key] = append(replaced[key], c)
}
}
// Modified unique/PK constraints are recreated via DROP + ADD
for _, cd := range td.ModifiedConstraints {
if cd.Old.Type == ir.ConstraintTypeUnique || cd.Old.Type == ir.ConstraintTypePrimaryKey {
replaced[key] = append(replaced[key], cd.Old)
}
}
}
if len(replaced) == 0 {
return nil, nil, nil
}

// Existing FKs bound to a replaced constraint
for _, tableKey := range sortedKeys(oldTables) {
oldTable := oldTables[tableKey]
newTable := newTables[tableKey]
if newTable == nil {
continue // table is dropped before the modify phase; its FKs go with it
}
for _, name := range sortedKeys(oldTable.Constraints) {
Comment thread
tianzhou marked this conversation as resolved.
fk := oldTable.Constraints[name]
if fk.Type != ir.ConstraintTypeForeignKey {
continue
}
newFK := newTable.Constraints[name]
oldBound := fkReferencesAnyConstraint(fk, replaced[fkReferencedTableKey(fk)])
// A changed FK whose new definition targets a replaced constraint
// must also wait for the replacement, even if its old definition
// was bound elsewhere.
newBound := newFK != nil && !constraintsEqual(fk, newFK) &&
fkReferencesAnyConstraint(newFK, replaced[fkReferencedTableKey(newFK)])
if !oldBound && !newBound {
continue
}

preDrops = append(preDrops, fk)
if newFK != nil {
postAdds = append(postAdds, &deferredConstraint{table: newTable, constraint: newFK})
}
}
}

// FKs on newly added tables that target a replaced constraint: keep them
// out of CREATE TABLE (create phase runs before the modify-phase swap) and
// add them with the other recreated FKs instead.
suppressedInlineFKs = make(map[string]bool)
for _, table := range addedTables {
for _, name := range sortedKeys(table.Constraints) {
fk := table.Constraints[name]
if fk.Type != ir.ConstraintTypeForeignKey {
continue
}
if !fkReferencesAnyConstraint(fk, replaced[fkReferencedTableKey(fk)]) {
continue
}
suppressedInlineFKs[constraintPathKey(fk)] = true
postAdds = append(postAdds, &deferredConstraint{table: table, constraint: fk})
}
}

sort.Slice(preDrops, func(i, j int) bool {
return constraintPathKey(preDrops[i]) < constraintPathKey(preDrops[j])
})
sort.Slice(postAdds, func(i, j int) bool {
return constraintPathKey(postAdds[i].constraint) < constraintPathKey(postAdds[j].constraint)
})
return preDrops, postAdds, suppressedInlineFKs
}

// constraintPathKey returns the schema-qualified identity of a constraint.
func constraintPathKey(c *ir.Constraint) string {
return c.Schema + "." + c.Table + "." + c.Name
}

// fkReferencedTableKey returns the schema-qualified key of the table an FK references.
func fkReferencedTableKey(fk *ir.Constraint) string {
refSchema := fk.ReferencedSchema
if refSchema == "" {
refSchema = fk.Schema
}
return refSchema + "." + fk.ReferencedTable
}

// fkReferencesAnyConstraint reports whether the FK's referenced columns match
// the column set of any of the given unique/PK constraints.
func fkReferencesAnyConstraint(fk *ir.Constraint, candidates []*ir.Constraint) bool {
refCols := make(map[string]bool, len(fk.ReferencedColumns))
for _, col := range fk.ReferencedColumns {
refCols[col.Name] = true
}
for _, c := range candidates {
if len(c.Columns) != len(refCols) {
continue
}
matched := true
for _, col := range c.Columns {
if !refCols[col.Name] {
matched = false
break
}
}
if matched {
return true
Comment thread
tianzhou marked this conversation as resolved.
}
}
return false
}
Comment thread
tianzhou marked this conversation as resolved.

// generateDropRecreatedFKsSQL drops foreign keys bound to unique/PK constraints
// being replaced. Must run before the table modifications. (#439)
func generateDropRecreatedFKsSQL(fks []*ir.Constraint, targetSchema string, collector *diffCollector) {
for _, fk := range fks {
tableName := getTableNameWithSchema(fk.Schema, fk.Table, targetSchema)
sql := fmt.Sprintf("ALTER TABLE %s DROP CONSTRAINT %s;", tableName, ir.QuoteIdentifier(fk.Name))

context := &diffContext{
Type: DiffTypeTableConstraint,
Operation: DiffOperationDrop,
Path: fmt.Sprintf("%s.%s.%s", fk.Schema, fk.Table, fk.Name),
Source: fk,
CanRunInTransaction: true,
}
collector.collect(context, sql)
}
}

Expand All @@ -583,7 +747,7 @@ func generateDropTablesSQL(tables []*ir.Table, targetSchema string, collector *d
}

// generateTableSQL generates CREATE TABLE statement and returns any deferred FK constraints
func generateTableSQL(table *ir.Table, targetSchema string, createdTables map[string]bool, existingTables map[string]bool) (string, []*deferredConstraint) {
func generateTableSQL(table *ir.Table, targetSchema string, createdTables map[string]bool, existingTables map[string]bool, suppressedInlineFKs map[string]bool) (string, []*deferredConstraint) {
// Only include table name without schema if it's in the target schema
tableName := ir.QualifyEntityNameWithQuotes(table.Schema, table.Name, targetSchema)

Expand Down Expand Up @@ -618,6 +782,11 @@ func generateTableSQL(table *ir.Table, targetSchema string, createdTables map[st
var deferred []*deferredConstraint
currentKey := fmt.Sprintf("%s.%s", table.Schema, table.Name)
for _, constraint := range inlineConstraints {
// FKs bound to a unique/PK constraint being replaced by this migration
// are created after the replacement instead (issue #439)
if suppressedInlineFKs[constraintPathKey(constraint)] {
continue
}
if shouldDeferConstraint(table, constraint, currentKey, createdTables, existingTables) {
deferred = append(deferred, &deferredConstraint{
table: table,
Expand Down Expand Up @@ -696,7 +865,10 @@ func constraintDroppedWithColumns(constraint *ir.Constraint, droppedColumnSet ma
// FK constraints referencing these tables are skipped since CASCADE already removes them.
// droppedColumnSet contains column names being dropped from this table; constraints that
// depend on those columns are skipped because DROP COLUMN already removes them. (#384)
func (td *tableDiff) generateAlterTableStatements(targetSchema string, collector *diffCollector, droppedTableSet map[string]bool, droppedColumnSet map[string]bool) {
// preDroppedFKSet contains "schema.table.constraint" keys for FKs already dropped in the
// pre-drop step because they were bound to a replaced unique/PK constraint; their drop
// and modify entries are skipped since the pre-drop/post-add steps handle them. (#439)
func (td *tableDiff) generateAlterTableStatements(targetSchema string, collector *diffCollector, droppedTableSet map[string]bool, droppedColumnSet map[string]bool, preDroppedFKSet map[string]bool) {
// Persistence change (UNLOGGED to LOGGED or vice versa) should emit first
// because PostgreSQL rewrites the heap so doing it before column/constraint
// changes reduces data movement on subsequent steps
Expand Down Expand Up @@ -725,6 +897,11 @@ func (td *tableDiff) generateAlterTableStatements(targetSchema string, collector
continue
}

// Skip FKs already dropped in the pre-drop step. (#439)
if preDroppedFKSet[constraintPathKey(constraint)] {
continue
}

// Skip FK constraints whose referenced table is being dropped with CASCADE,
// since the CASCADE will already remove the constraint. (#382)
if constraint.Type == ir.ConstraintTypeForeignKey && constraint.ReferencedTable != "" {
Expand Down Expand Up @@ -1013,6 +1190,12 @@ func (td *tableDiff) generateAlterTableStatements(targetSchema string, collector

// Handle modified constraints - drop and recreate them as separate operations
for _, ConstraintDiff := range td.ModifiedConstraints {
// Skip FKs already dropped in the pre-drop step; the post-add step
// recreates them from the desired-state definition. (#439)
if preDroppedFKSet[constraintPathKey(ConstraintDiff.Old)] {
continue
}

tableName := getTableNameWithSchema(td.Table.Schema, td.Table.Name, targetSchema)
constraint := ConstraintDiff.New

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
CREATE TABLE IF NOT EXISTS case_files (
id integer,
case_id integer,
CONSTRAINT case_files_pkey PRIMARY KEY (id)
);

ALTER TABLE case_comments DROP CONSTRAINT case_comments_case_id_fkey;

ALTER TABLE case_notes DROP CONSTRAINT case_notes_case_id_fkey;

ALTER TABLE case_tags DROP CONSTRAINT case_tags_case_id_fkey;

ALTER TABLE cases DROP CONSTRAINT cases_id_key;

ALTER TABLE cases
ADD CONSTRAINT cases_pkey PRIMARY KEY (id);

ALTER TABLE case_comments
ADD CONSTRAINT case_comments_case_id_fkey FOREIGN KEY (case_id) REFERENCES cases (id) ON DELETE CASCADE;

ALTER TABLE case_files
ADD CONSTRAINT case_files_case_id_fkey FOREIGN KEY (case_id) REFERENCES cases (id);

ALTER TABLE case_notes
ADD CONSTRAINT case_notes_case_id_fkey FOREIGN KEY (case_id) REFERENCES cases (id);

ALTER TABLE case_tags
ADD CONSTRAINT case_tags_case_id_fkey FOREIGN KEY (case_id) REFERENCES cases (id) ON DELETE CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
CREATE TABLE cases (
id integer PRIMARY KEY,
name text
);

-- unchanged FK
CREATE TABLE case_notes (
id integer PRIMARY KEY,
case_id integer CONSTRAINT case_notes_case_id_fkey REFERENCES cases (id)
);

-- unchanged FK with ON DELETE CASCADE (clause must survive recreation)
CREATE TABLE case_tags (
id integer PRIMARY KEY,
case_id integer CONSTRAINT case_tags_case_id_fkey REFERENCES cases (id) ON DELETE CASCADE
);

-- FK that itself changes in the same migration (gains ON DELETE CASCADE)
CREATE TABLE case_comments (
id integer PRIMARY KEY,
case_id integer CONSTRAINT case_comments_case_id_fkey REFERENCES cases (id) ON DELETE CASCADE
);

-- newly added table whose FK references the replaced constraint
CREATE TABLE case_files (
id integer PRIMARY KEY,
case_id integer CONSTRAINT case_files_case_id_fkey REFERENCES cases (id)
);
Loading