diff --git a/migrate.go b/migrate.go index 83f28cd35..d614a05ae 100644 --- a/migrate.go +++ b/migrate.go @@ -5,9 +5,13 @@ package migrate import ( + "bytes" "errors" "fmt" + "io" "os" + "regexp" + "strings" "sync" "time" @@ -55,61 +59,6 @@ func (e ErrDirty) Error() string { return fmt.Sprintf("Dirty database version %v. Fix and force version.", e.Version) } -// PostStepCallback is a callback function type that can be used to execute a -// Golang based migration step after a SQL based migration step has been -// executed. The callback function receives the migration and the database -// driver as arguments. -type PostStepCallback func(migr *Migration, driver database.Driver) error - -// options is a set of optional options that can be set when a Migrate instance -// is created. -type options struct { - // postStepCallbacks is a map of PostStepCallback functions that can be - // used to execute a Golang based migration step after a SQL based - // migration step has been executed. The key is the migration version - // and the value is the callback function that should be run _after_ the - // step was executed (but within the same database transaction). - postStepCallbacks map[uint]PostStepCallback -} - -// defaultOptions returns a new options struct with default values. -func defaultOptions() options { - return options{ - postStepCallbacks: make(map[uint]PostStepCallback), - } -} - -// Option is a function that can be used to set options on a Migrate instance. -type Option func(*options) - -// WithPostStepCallbacks is an option that can be used to set a map of -// PostStepCallback functions that can be used to execute a Golang based -// migration step after a SQL based migration step has been executed. The key is -// the migration version and the value is the callback function that should be -// run _after_ the step was executed (but before the version is marked as -// cleanly executed). An error returned from the callback will cause the -// migration to fail and the step to be marked as dirty. -func WithPostStepCallbacks( - postStepCallbacks map[uint]PostStepCallback) Option { - - return func(o *options) { - o.postStepCallbacks = postStepCallbacks - } -} - -// WithPostStepCallback is an option that can be used to set a PostStepCallback -// function that can be used to execute a Golang based migration step after the -// SQL based migration step with the given version number has been executed. The -// callback is the function that should be run _after_ the step was executed -// (but before the version is marked as cleanly executed). An error returned -// from the callback will cause the migration to fail and the step to be marked -// as dirty. -func WithPostStepCallback(version uint, callback PostStepCallback) Option { - return func(o *options) { - o.postStepCallbacks[version] = callback - } -} - type Migrate struct { sourceName string sourceDrv source.Driver @@ -787,6 +736,34 @@ func (m *Migrate) readDown(from int, limit int, ret chan<- interface{}) { } } +// hasSQLMigration checks if the passed data contains executable statements, +// meaning that the data doesn't only contain comments/whitespace or semicolons. +func (m *Migrate) hasSQLMigration(data []byte) (bool, error) { + s := string(data) + + // Remove Byte Order Mark (BOM) if present in the migration file. + s = strings.TrimPrefix(s, "\uFEFF") + + // Strip block comments /* ... */ (non-greedy, across lines). + reBlock := regexp.MustCompile(`(?s)/\*.*?\*/`) + s = reBlock.ReplaceAllString(s, "") + + // Strip line comments -- ... (to end of line). + reLine := regexp.MustCompile(`(?m)--[^\n\r]*`) + s = reLine.ReplaceAllString(s, "") + + // Trim whitespaces. + s = strings.TrimSpace(s) + + // Remove any semicolons, newlines, tabs, or spaces from the beginning + // and end of the string. + s = strings.Trim(s, ";\r\n\t ") + + // If the string still contains any characters, the data likely + // contains executable statements. + return len(s) > 0, nil +} + // runMigrations reads *Migration and error from a channel. Any other type // sent on this channel will result in a panic. Each migration is then // proxied to the database driver and run against the database. @@ -807,34 +784,58 @@ func (m *Migrate) runMigrations(ret <-chan interface{}) error { case *Migration: migr := r - // set version with dirty state - if err := m.databaseDrv.SetVersion(migr.TargetVersion, true); err != nil { - return err - } - if migr.Body != nil { - m.logVerbosePrintf("Read and execute %v\n", migr.LogString()) - if err := m.databaseDrv.Run(migr.BufferedBody); err != nil { + // Read the body so we can inspect and (re)use it. + data, err := io.ReadAll(migr.BufferedBody) + if err != nil { + return fmt.Errorf("read migration body: %w", err) + } + + // Reset the reader so the driver can read it + migr.BufferedBody = bytes.NewReader(data) + + // Check if the migration contains an SQL + // migration. + hasSqlMig, err := m.hasSQLMigration(data) + if err != nil { return err } - // If there is a post execution function for - // this migration, run it now. - cb, ok := m.opts.postStepCallbacks[migr.Version] - if ok { - m.logVerbosePrintf("Running post step "+ - "callback for %v\n", migr.LogString()) + // Check if the migration contains a migration + // task. + _, hasMigTask := m.opts.tasks[migr.Version] + + // Execute the SQL migration or the migration + // task. + switch { + case hasSqlMig && hasMigTask: + return fmt.Errorf("migration has both " + + "a SQL migration and a " + + "migration task set") + + case hasSqlMig: + if err = m.databaseDrv.SetVersion(migr.TargetVersion, true); err != nil { + return err + } - err := cb(migr, m.databaseDrv) + m.logVerbosePrintf("Read and execute %v\n", migr.LogString()) + if err = m.databaseDrv.Run(migr.BufferedBody); err != nil { + return err + } + + case hasMigTask: + err = m.execTask(migr) if err != nil { - return fmt.Errorf("failed to "+ - "execute post "+ - "step callback: %w", - err) + return fmt.Errorf("migration "+ + "task execution "+ + "failed: %w", err) } - m.logVerbosePrintf("Post step callback "+ - "finished for %v\n", migr.LogString()) + default: + // When the migration contains no SQL + // migration or migration task, we + // continue and set the version to the + // migr.TargetVersion. } } @@ -863,6 +864,59 @@ func (m *Migrate) runMigrations(ret <-chan interface{}) error { return nil } +// execTask checks if a migration task exists for the passed migration and +// proceeds to execute if one exists. If the migration task fails, the function +// will reset the database version to the version it was set to before +// attempting to execute the migration task. +func (m *Migrate) execTask(migr *Migration) error { + m.logVerbosePrintf("Running migration task for %v\n", migr.LogString()) + + task, ok := m.opts.tasks[migr.Version] + if !ok { + return fmt.Errorf("no migration task set for %v", + migr.LogString()) + } + + // Get the current database version before executing the migration task. + curVersion, dirty, err := m.databaseDrv.Version() + if err != nil { + return fmt.Errorf("unable to get current version: %w", err) + } + + if dirty { + return ErrDirty{curVersion} + } + + // Persist that we are at the migration version of the migration task. + if err = m.databaseDrv.SetVersion(int(migr.Version), true); err != nil { + return err + } + + err = task(migr, m.databaseDrv) + if err != nil { + // Reset the version to the version set before executing the + // migration task. Therefore, the migration task will be + // re-executed on nnext startup until it succeeds. + setErr := m.databaseDrv.SetVersion(curVersion, false) + if setErr != nil { + // Note that if we error here, the database version will + // remain in a dirty state. As we cannot know if the + // migration task was executed or not in that scenario, + // manual intervention is required. + return fmt.Errorf("WARNING, failed to set migration "+ + "version after migration task errored. Manual "+ + "intervention needed! Migration task error: "+ + "%w, version setting error : %w", err, setErr) + } + + return fmt.Errorf("failed to execute migration task: %w", err) + } + + m.logVerbosePrintf("Migration task finished for %v\n", migr.LogString()) + + return nil +} + // versionExists checks the source if either the up or down migration for // the specified migration version exists. func (m *Migrate) versionExists(version uint) (result error) { diff --git a/migrate_test.go b/migrate_test.go index a19c4f214..b71df8fc5 100644 --- a/migrate_test.go +++ b/migrate_test.go @@ -23,6 +23,15 @@ import ( // | u d | - | u | u d | d | - | u d | var sourceStubMigrations *source.Migrations +// sourceMigrationTaskMigrations hold the following migrations: +// u = up migration, d = down migration, n = version, SQL = SQL migration, +// TSK = migration task +// +// | 1 | 2 | 3 | 4 | 5 | - | 7 | +// | SQL | TSK | SQL | SQL | SQL | - | TSK | +// | u d | u d | u | u d | d | - | u | +var sourceMigrationTaskMigrations *source.Migrations + const ( srcDrvNameStub = "stub" dbDrvNameStub = "stub" @@ -38,6 +47,17 @@ func init() { sourceStubMigrations.Append(&source.Migration{Version: 5, Direction: source.Down, Identifier: "DROP 5"}) sourceStubMigrations.Append(&source.Migration{Version: 7, Direction: source.Up, Identifier: "CREATE 7"}) sourceStubMigrations.Append(&source.Migration{Version: 7, Direction: source.Down, Identifier: "DROP 7"}) + + sourceMigrationTaskMigrations = source.NewMigrations() + sourceMigrationTaskMigrations.Append(&source.Migration{Version: 1, Direction: source.Up, Identifier: "CREATE 1"}) + sourceMigrationTaskMigrations.Append(&source.Migration{Version: 1, Direction: source.Down, Identifier: "DROP 1"}) + sourceMigrationTaskMigrations.Append(&source.Migration{Version: 2, Direction: source.Up, Identifier: ""}) + sourceMigrationTaskMigrations.Append(&source.Migration{Version: 2, Direction: source.Down, Identifier: ""}) + sourceMigrationTaskMigrations.Append(&source.Migration{Version: 3, Direction: source.Up, Identifier: "CREATE 3"}) + sourceMigrationTaskMigrations.Append(&source.Migration{Version: 4, Direction: source.Up, Identifier: "CREATE 4"}) + sourceMigrationTaskMigrations.Append(&source.Migration{Version: 4, Direction: source.Down, Identifier: "DROP 4"}) + sourceMigrationTaskMigrations.Append(&source.Migration{Version: 5, Direction: source.Down, Identifier: "DROP 5"}) + sourceMigrationTaskMigrations.Append(&source.Migration{Version: 7, Direction: source.Up, Identifier: ""}) } type DummyInstance struct{ Name string } @@ -879,12 +899,12 @@ func TestUpAndDown(t *testing.T) { equalDbSeq(t, 1, expectedSequence, dbDrv) } -func TestPostStepCallback(t *testing.T) { - m, _ := New("stub://", "stub://", WithPostStepCallbacks( - map[uint]PostStepCallback{ - 1: func(m *Migration, driver database.Driver) error { +func TestMigrationTask(t *testing.T) { + m, _ := New("stub://", "stub://", WithMigrationTasks( + map[uint]MigrationTask{ + 2: func(m *Migration, driver database.Driver) error { return driver.Run( - strings.NewReader("CALLBACK 1"), + strings.NewReader("CALLBACK 2"), ) }, 7: func(m *Migration, driver database.Driver) error { @@ -894,7 +914,7 @@ func TestPostStepCallback(t *testing.T) { }, }, )) - m.sourceDrv.(*sStub.Stub).Migrations = sourceStubMigrations + m.sourceDrv.(*sStub.Stub).Migrations = sourceMigrationTaskMigrations dbDrv := m.databaseDrv.(*dStub.Stub) // go Up first @@ -903,10 +923,9 @@ func TestPostStepCallback(t *testing.T) { } expectedSequence := migrationSequence{ mr("CREATE 1"), - mr("CALLBACK 1"), + mr("CALLBACK 2"), mr("CREATE 3"), mr("CREATE 4"), - mr("CREATE 7"), mr("CALLBACK 7"), } equalDbSeq(t, 0, expectedSequence, dbDrv) @@ -920,24 +939,25 @@ func TestPostStepCallback(t *testing.T) { if err := m.Down(); err != nil { t.Fatal(err) } + + // Note that "CALLBACK 7" isn't repeated when going down, as that + // migration source only contains an .up migration for version 7, and + // not at .down migration. expectedSequence = migrationSequence{ mr("CREATE 1"), - mr("CALLBACK 1"), + mr("CALLBACK 2"), mr("CREATE 3"), mr("CREATE 4"), - mr("CREATE 7"), - mr("CALLBACK 7"), - mr("DROP 7"), mr("CALLBACK 7"), mr("DROP 5"), mr("DROP 4"), + mr("CALLBACK 2"), mr("DROP 1"), - mr("CALLBACK 1"), } equalDbSeq(t, 1, expectedSequence, dbDrv) - if !bytes.Equal(dbDrv.LastRunMigration, []byte("CALLBACK 1")) { - t.Fatalf("expected database last migration to be callback 1, "+ + if !bytes.Equal(dbDrv.LastRunMigration, []byte("DROP 1")) { + t.Fatalf("expected database last migration to be DROP 1, "+ "got %s", dbDrv.LastRunMigration) } @@ -947,19 +967,15 @@ func TestPostStepCallback(t *testing.T) { } expectedSequence = migrationSequence{ mr("CREATE 1"), - mr("CALLBACK 1"), + mr("CALLBACK 2"), mr("CREATE 3"), mr("CREATE 4"), - mr("CREATE 7"), - mr("CALLBACK 7"), - mr("DROP 7"), mr("CALLBACK 7"), mr("DROP 5"), mr("DROP 4"), + mr("CALLBACK 2"), mr("DROP 1"), - mr("CALLBACK 1"), mr("CREATE 1"), - mr("CALLBACK 1"), } equalDbSeq(t, 2, expectedSequence, dbDrv) @@ -968,27 +984,304 @@ func TestPostStepCallback(t *testing.T) { } expectedSequence = migrationSequence{ mr("CREATE 1"), - mr("CALLBACK 1"), + mr("CALLBACK 2"), mr("CREATE 3"), mr("CREATE 4"), - mr("CREATE 7"), - mr("CALLBACK 7"), - mr("DROP 7"), mr("CALLBACK 7"), mr("DROP 5"), mr("DROP 4"), + mr("CALLBACK 2"), mr("DROP 1"), - mr("CALLBACK 1"), mr("CREATE 1"), - mr("CALLBACK 1"), + mr("CALLBACK 2"), mr("CREATE 3"), mr("CREATE 4"), - mr("CREATE 7"), mr("CALLBACK 7"), } equalDbSeq(t, 3, expectedSequence, dbDrv) } +func TestMigrationTaskError(t *testing.T) { + // This test simulates a migration task that fails, and ensures that: + // 1) The migration process stops and returns the task error. + // 2) The migration version is set to the migration version set prior + // to executing the migration task if the task errors. + // 3) Re-running Up will re-attempt the migration task. + // 4) Down will not re-execute a migration task if it errored, as the + // version should have been set to the version prior to executing the + // migration task. + // 5) Once the migration task succeeds, the migration can finalize + // and reach the latest version cleanly. + // 6) Down will only execute the migration task at a given version if a + // ".down" file is defined for the migration task. + var ( + cbError = errors.New("migration task failure") + shouldFail = true + ) + + // The migration task for version 2 will error if shouldFail == false, + // and succeed if it is set to true. + m, _ := New("stub://", "stub://", WithMigrationTasks( + map[uint]MigrationTask{ + 2: func(migr *Migration, driver database.Driver) error { + // record that the task was executed + if shouldFail { + err := driver.Run(strings.NewReader( + "CALLBACK 2 FAILURE", + )) + if err != nil { + return err + } + + return cbError + } + + err := driver.Run(strings.NewReader( + "CALLBACK 2 SUCCESS", + )) + if err != nil { + return err + } + + return nil + }, + 7: func(m *Migration, driver database.Driver) error { + return driver.Run( + strings.NewReader("CALLBACK 7"), + ) + }, + }, + )) + + m.sourceDrv.(*sStub.Stub).Migrations = sourceMigrationTaskMigrations + dbDrv := m.databaseDrv.(*dStub.Stub) + + // Helper to check the migration version and dirty state. + checkVersion := func(expVer int) { + v, dirty, err := dbDrv.Version() + if err != nil { + t.Fatal(err) + } + + if v != expVer { + t.Fatalf("expected version %d, got v=%d", expVer, v) + } + if dirty { + t.Fatalf("expected clean version, but was dirty") + } + } + + // 1) Run Up — the migration task for 2 should fail. + err := m.Up() + if !errors.Is(err, cbError) { + t.Fatal("expected cbError from failing migration task") + } + + // The sequence should show the migrations prior to version 2, and then + // the failing callback for version 2. + expectedSequence := migrationSequence{ + mr("CREATE 1"), + mr("CALLBACK 2 FAILURE"), + } + equalDbSeq(t, 0, expectedSequence, dbDrv) + + if !bytes.Equal(dbDrv.LastRunMigration, []byte("CALLBACK 2 FAILURE")) { + t.Fatalf("expected database last migration to be callback 2, "+ + "got %s", dbDrv.LastRunMigration) + } + + // 2) Due to the that the migration task errored, the version should + // have been reset to the version set before the migration task was + // executed. + checkVersion(1) + + // 3) Re-run Up — since the database version is set to version 1, it + // should try the migration task again at version 2 and fail again. + err = m.Up() + if !errors.Is(err, cbError) { + t.Fatal("expected cbError from failing migration task") + } + + // The migration task for version 2 should now have failed twice. + expectedSequence = migrationSequence{ + mr("CREATE 1"), + mr("CALLBACK 2 FAILURE"), + mr("CALLBACK 2 FAILURE"), + } + equalDbSeq(t, 0, expectedSequence, dbDrv) + + if !bytes.Equal(dbDrv.LastRunMigration, []byte("CALLBACK 2 FAILURE")) { + t.Fatalf("expected database last migration to be callback 2, "+ + "got %s", dbDrv.LastRunMigration) + } + + // The version should have been reset to 1 once again. + checkVersion(1) + + // 4) Execute down — as the version should have been reset to 1, this + // should not re-execute the migration task for version 2. + err = m.Down() + + expectedSequence = migrationSequence{ + mr("CREATE 1"), + mr("CALLBACK 2 FAILURE"), + mr("CALLBACK 2 FAILURE"), + mr("DROP 1"), + } + equalDbSeq(t, 0, expectedSequence, dbDrv) + + if !bytes.Equal(dbDrv.LastRunMigration, []byte("DROP 1")) { + t.Fatalf("expected database last migration to be DROP 1, "+ + "got %s", dbDrv.LastRunMigration) + } + + // The version should now be at -1, as the migration for version 1 has + // been dropped. + checkVersion(-1) + + // 5) Make the callback succeed by setting shouldFail to false and run + // again. It should now successfully re-run the callback and then + // finalize the migration cleanly to the latest version (7). + shouldFail = false + + err = m.Up() + expectedSequence = migrationSequence{ + mr("CREATE 1"), + mr("CALLBACK 2 FAILURE"), + mr("CALLBACK 2 FAILURE"), + mr("DROP 1"), + mr("CREATE 1"), + mr("CALLBACK 2 SUCCESS"), + mr("CREATE 3"), + mr("CREATE 4"), + mr("CALLBACK 7"), + } + equalDbSeq(t, 0, expectedSequence, dbDrv) + + // And the last run migration should be from the callback. + if !bytes.Equal(dbDrv.LastRunMigration, []byte("CALLBACK 7")) { + t.Fatalf("expected last run migration to be from migration "+ + "task, got %q", dbDrv.LastRunMigration) + } + + // The version should now be the latest non migration task version 2 + // and not dirty. + checkVersion(7) + + // Try Up again — it should be a no-op since we are at the latest + // version, and shouldn't run the task again. + err = m.Up() + if !errors.Is(err, ErrNoChange) { + t.Fatalf("unexpected error after migration task succeed: %v", + err) + } + + // Ensure the callback wasn't re-run. + expectedSequence = migrationSequence{ + mr("CREATE 1"), + mr("CALLBACK 2 FAILURE"), + mr("CALLBACK 2 FAILURE"), + mr("DROP 1"), + mr("CREATE 1"), + mr("CALLBACK 2 SUCCESS"), + mr("CREATE 3"), + mr("CREATE 4"), + mr("CALLBACK 7"), + } + equalDbSeq(t, 0, expectedSequence, dbDrv) + + // And the last run migration should be from the previous callback. + if !bytes.Equal(dbDrv.LastRunMigration, []byte("CALLBACK 7")) { + t.Fatalf("expected last run migration to be from migration "+ + "task, got %q", dbDrv.LastRunMigration) + } + + checkVersion(7) + + // 6) Finally, running Down now should only execute the migration task + // at for version 2, as that migration task is the only tash which + // ".down" file is defined for the migration task. + err = m.Down() + + expectedSequence = migrationSequence{ + mr("CREATE 1"), + mr("CALLBACK 2 FAILURE"), + mr("CALLBACK 2 FAILURE"), + mr("DROP 1"), + mr("CREATE 1"), + mr("CALLBACK 2 SUCCESS"), + mr("CREATE 3"), + mr("CREATE 4"), + mr("CALLBACK 7"), + mr("DROP 5"), + mr("DROP 4"), + mr("CALLBACK 2 SUCCESS"), + mr("DROP 1"), + } + equalDbSeq(t, 0, expectedSequence, dbDrv) + + if !bytes.Equal(dbDrv.LastRunMigration, []byte("DROP 1")) { + t.Fatalf("expected database last migration to be DROP 1, "+ + "got %s", dbDrv.LastRunMigration) + } + + // The version should now be at -1, as the migration for version 1 has + // been dropped. + checkVersion(-1) +} + +func TestMigrationTypeCombos(t *testing.T) { + // This test ensures that a migration cannot be both an SQL migration + // and a Migration task at the same time. It also tests that a migration + // can be neither an SQL migration nor a Migration Task. + + // Test that a migration can't be both an SQL migration and a Migration + // task at the same time. + m1, _ := New("stub://", "stub://", WithMigrationTasks( + map[uint]MigrationTask{ + 1: func(m *Migration, driver database.Driver) error { + return driver.Run( + strings.NewReader("CALLBACK 1"), + ) + }, + }, + )) + + bothTypeMig := source.NewMigrations() + bothTypeMig.Append( + &source.Migration{Version: 1, Direction: source.Up, + Identifier: "CREATE 1"}, + ) + + m1.sourceDrv.(*sStub.Stub).Migrations = bothTypeMig + + err := m1.Up() + if err == nil || !strings.Contains(err.Error(), + "migration has both a SQL migration and a migration task set") { + + t.Fatal("expected an error indicating that a migration can't" + + "be both an SQL migration and a migration task") + } + + // Test that a migration can be neither an SQL migration nor a Migration + // task. + m2, _ := New("stub://", "stub://") + + noTypeMig := source.NewMigrations() + noTypeMig.Append( + &source.Migration{Version: 1, Direction: source.Up, + Identifier: ""}, + ) + + m2.sourceDrv.(*sStub.Stub).Migrations = noTypeMig + + err = m2.Up() + if err != nil { + t.Fatal("expected an error no error when a migration was " + + "neither an SQL migration nor a migration task") + } +} + func TestUpDirty(t *testing.T) { m, _ := New("stub://", "stub://") dbDrv := m.databaseDrv.(*dStub.Stub) diff --git a/migration_task.go b/migration_task.go new file mode 100644 index 000000000..43bd3a3ca --- /dev/null +++ b/migration_task.go @@ -0,0 +1,56 @@ +package migrate + +import "github.com/golang-migrate/migrate/v4/database" + +// MigrationTask is a callback function type that can be used to execute a +// Golang based migration step after a SQL based migration step has been +// executed. The callback function receives the migration and the database +// driver as arguments. +type MigrationTask func(migr *Migration, driver database.Driver) error + +// options is a set of optional options that can be set when a Migrate instance +// is created. +type options struct { + // tasks is a map of MigrationTask functions that can be used to execute + // a Golang based migration step after a SQL based migration step ha + // been executed. The key is the migration version and the value is the + // callback function that should be run _after_ the step was executed + // (but within the same database transaction). + tasks map[uint]MigrationTask +} + +// defaultOptions returns a new options struct with default values. +func defaultOptions() options { + return options{ + tasks: make(map[uint]MigrationTask), + } +} + +// Option is a function that can be used to set options on a Migrate instance. +type Option func(*options) + +// WithMigrationTasks is an option that can be used to set a map of +// MigrationTask functions that can be used to execute a Golang based migration +// step after a SQL based migration step has been executed. The key is the +// migration version and the value is the task function that should be run +// _after_ the step was executed (but before the version is marked as cleanly +// executed). An error returned from the task will cause the migration to fail +// and the step to be marked as dirty. +func WithMigrationTasks(tasks map[uint]MigrationTask) Option { + return func(o *options) { + o.tasks = tasks + } +} + +// WithMigrationTask is an option that can be used to set a MigrationTask +// function that can be used to execute a Golang based migration step after the +// SQL based migration step with the given version number has been executed. The +// task is the function that should be run _after_ the step was executed +// (but before the version is marked as cleanly executed). An error returned +// from the task will cause the migration to fail and the step to be marked as +// dirty. +func WithMigrationTask(version uint, task MigrationTask) Option { + return func(o *options) { + o.tasks[version] = task + } +}