fix: enforce no foreign keys in new migrations
This commit is contained in:
@@ -176,6 +176,9 @@ func Migrate(ctx context.Context, db *sql.DB) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("migration %d: %w", m.Version, err)
|
return fmt.Errorf("migration %d: %w", m.Version, err)
|
||||||
}
|
}
|
||||||
|
if err := validateNoReferenceDDL(m.Version, statements); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if _, err := conn.ExecContext(ctx, "INSERT INTO orbit_schema_migrations (version,name,checksum,dirty) VALUES (?,?,?,TRUE)", m.Version, m.Name, m.Checksum); err != nil {
|
if _, err := conn.ExecContext(ctx, "INSERT INTO orbit_schema_migrations (version,name,checksum,dirty) VALUES (?,?,?,TRUE)", m.Version, m.Name, m.Checksum); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -191,6 +194,24 @@ func Migrate(ctx context.Context, db *sql.DB) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateNoReferenceDDL protects the post-removal migration line at runtime.
|
||||||
|
// Versions 1-8 are immutable compatibility history: version 8 removes the
|
||||||
|
// legacy constraints from databases that were created before the no-FK policy.
|
||||||
|
// Every later migration must be incapable of creating or retaining a database
|
||||||
|
// reference constraint, even if a future author forgets the static test.
|
||||||
|
func validateNoReferenceDDL(version uint64, statements []string) error {
|
||||||
|
if version <= 8 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, statement := range statements {
|
||||||
|
upper := strings.ToUpper(statement)
|
||||||
|
if strings.Contains(upper, "FOREIGN KEY") || strings.Contains(upper, " REFERENCES ") {
|
||||||
|
return fmt.Errorf("migration %d contains forbidden foreign-key DDL", version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// applyFreshInstallBaseline creates a new database directly at the current
|
// applyFreshInstallBaseline creates a new database directly at the current
|
||||||
// schema version. It records every historical migration as dirty before DDL so
|
// schema version. It records every historical migration as dirty before DDL so
|
||||||
// an interrupted baseline remains blocked for operator reconciliation, just
|
// an interrupted baseline remains blocked for operator reconciliation, just
|
||||||
|
|||||||
@@ -51,3 +51,15 @@ func TestFreshInstallBaselineContainsNoReferenceDDL(t *testing.T) {
|
|||||||
t.Fatalf("baseline version=%d, migrations=%d", freshInstallBaselineVersion, len(migrations))
|
t.Fatalf("baseline version=%d, migrations=%d", freshInstallBaselineVersion, len(migrations))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPostRemovalMigrationGuard(t *testing.T) {
|
||||||
|
if err := validateNoReferenceDDL(9, []string{"ALTER TABLE pulse_tasks ADD CONSTRAINT fk FOREIGN KEY (queue_id) REFERENCES pulse_queues(id)"}); err == nil {
|
||||||
|
t.Fatal("post-removal migration accepted foreign-key DDL")
|
||||||
|
}
|
||||||
|
if err := validateNoReferenceDDL(9, []string{"ALTER TABLE pulse_tasks ADD INDEX ix_queue (queue_id)"}); err != nil {
|
||||||
|
t.Fatalf("valid post-removal migration rejected: %v", err)
|
||||||
|
}
|
||||||
|
if err := validateNoReferenceDDL(8, []string{"ALTER TABLE pulse_tasks DROP FOREIGN KEY legacy_fk"}); err != nil {
|
||||||
|
t.Fatalf("immutable removal migration rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user