diff --git a/README.md b/README.md index 28f9ddf67..9d3822ce5 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Goose supports [embedding SQL migrations](#embedded-sql-migrations), which means - goose pkg doesn't have any vendor dependencies anymore - We use timestamped migrations by default but recommend a hybrid approach of using timestamps in the development process and sequential versions in production. - Supports missing (out-of-order) migrations with the `-allow-missing` flag, or if using as a library supply the functional option `goose.WithAllowMissing()` to Up, UpTo or UpByOne. +- Supports performing dry runs with the `-dry-run` flag, or if using as a library supply the functional option `goose.WithDryRun()` to Up, UpTo or UpByOne. - Supports applying ad-hoc migrations without tracking them in the schema table. Useful for seeding a database after migrations have been applied. Use `-no-versioning` flag or the functional option `goose.WithNoVersioning()`. # Install @@ -86,6 +87,8 @@ Options: file path to root CA's certificates in pem format (only supported on mysql) -dir string directory with migration files (default ".") + -dry-run + prints out the migrations it would apply and exits before applying them -h print help -no-versioning apply migration commands with no versioning, in file order, from directory pointed to diff --git a/cmd/goose/main.go b/cmd/goose/main.go index 489e096ce..d72500985 100644 --- a/cmd/goose/main.go +++ b/cmd/goose/main.go @@ -35,6 +35,7 @@ var ( sslkey = flags.String("ssl-key", "", "file path to SSL key in pem format (only support on mysql)") noVersioning = flags.Bool("no-versioning", false, "apply migration commands with no versioning, in file order, from directory pointed to") noColor = flags.Bool("no-color", false, "disable color output (NO_COLOR env variable supported)") + isDryRun = flags.Bool("dry-run", false, "prints out the migrations it would apply and exits before applying them") ) var ( gooseVersion = "" @@ -145,6 +146,9 @@ func main() { if *allowMissing { options = append(options, goose.WithAllowMissing()) } + if *isDryRun { + options = append(options, goose.WithDryRun()) + } if *noVersioning { options = append(options, goose.WithNoVersioning()) } diff --git a/down.go b/down.go index c58c2144c..052bbef6d 100644 --- a/down.go +++ b/down.go @@ -21,7 +21,7 @@ func Down(db *sql.DB, dir string, opts ...OptionsFunc) error { } currentVersion := migrations[len(migrations)-1].Version // Migrate only the latest migration down. - return downToNoVersioning(db, migrations, currentVersion-1) + return downToNoVersioning(db, migrations, currentVersion-1, option.isDryRun) } currentVersion, err := GetDBVersion(db) if err != nil { @@ -31,7 +31,7 @@ func Down(db *sql.DB, dir string, opts ...OptionsFunc) error { if err != nil { return fmt.Errorf("no migration %v", currentVersion) } - return current.Down(db) + return current.Down(db, option.toMigrationOptionsFunc()) } // DownTo rolls back migrations to a specific version. @@ -45,7 +45,7 @@ func DownTo(db *sql.DB, dir string, version int64, opts ...OptionsFunc) error { return err } if option.noVersioning { - return downToNoVersioning(db, migrations, version) + return downToNoVersioning(db, migrations, version, option.isDryRun) } for { @@ -69,7 +69,7 @@ func DownTo(db *sql.DB, dir string, version int64, opts ...OptionsFunc) error { return nil } - if err = current.Down(db); err != nil { + if err = current.Down(db, option.toMigrationOptionsFunc()); err != nil { return err } } @@ -77,7 +77,7 @@ func DownTo(db *sql.DB, dir string, version int64, opts ...OptionsFunc) error { // downToNoVersioning applies down migrations down to, but not including, the // target version. -func downToNoVersioning(db *sql.DB, migrations Migrations, version int64) error { +func downToNoVersioning(db *sql.DB, migrations Migrations, version int64, isDryRun bool) error { var finalVersion int64 for i := len(migrations) - 1; i >= 0; i-- { if version >= migrations[i].Version { @@ -85,7 +85,10 @@ func downToNoVersioning(db *sql.DB, migrations Migrations, version int64) error break } migrations[i].noVersioning = true - if err := migrations[i].Down(db); err != nil { + migrationOptFunc := func(opt *migrationOptions) { + opt.isDryRun = isDryRun + } + if err := migrations[i].Down(db, migrationOptFunc); err != nil { return err } } diff --git a/migration.go b/migration.go index f3727338e..77d4467f1 100644 --- a/migration.go +++ b/migration.go @@ -20,6 +20,16 @@ type MigrationRecord struct { IsApplied bool // was this a result of up() or down() } +type migrationOptions struct { + isDryRun bool +} + +type MigrationOptionsFunc func(o *migrationOptions) + +func MigrationWithDryRun() MigrationOptionsFunc { + return func(mo *migrationOptions) { mo.isDryRun = true } +} + // Migration struct. type Migration struct { Version int64 @@ -38,24 +48,32 @@ func (m *Migration) String() string { } // Up runs an up migration. -func (m *Migration) Up(db *sql.DB) error { +func (m *Migration) Up(db *sql.DB, opts ...MigrationOptionsFunc) error { ctx := context.Background() - if err := m.run(ctx, db, true); err != nil { + option := &migrationOptions{} + for _, f := range opts { + f(option) + } + if err := m.run(ctx, db, true /* direction */, option.isDryRun); err != nil { return err } return nil } // Down runs a down migration. -func (m *Migration) Down(db *sql.DB) error { +func (m *Migration) Down(db *sql.DB, opts ...MigrationOptionsFunc) error { ctx := context.Background() - if err := m.run(ctx, db, false); err != nil { + option := &migrationOptions{} + for _, f := range opts { + f(option) + } + if err := m.run(ctx, db, false /* direction */, option.isDryRun); err != nil { return err } return nil } -func (m *Migration) run(ctx context.Context, db *sql.DB, direction bool) error { +func (m *Migration) run(ctx context.Context, db *sql.DB, direction bool, isDryRun bool) error { switch filepath.Ext(m.Source) { case ".sql": f, err := baseFS.Open(m.Source) @@ -69,6 +87,10 @@ func (m *Migration) run(ctx context.Context, db *sql.DB, direction bool) error { return fmt.Errorf("ERROR %v: failed to parse SQL migration file: %w", filepath.Base(m.Source), err) } + if isDryRun { + log.Printf("DRY-RUN %s\n", filepath.Base(m.Source)) + return nil + } start := time.Now() if err := runSQLMigration(ctx, db, statements, useTx, m.Version, direction, m.noVersioning); err != nil { return fmt.Errorf("ERROR %v: failed to run SQL migration: %w", filepath.Base(m.Source), err) @@ -76,15 +98,19 @@ func (m *Migration) run(ctx context.Context, db *sql.DB, direction bool) error { finish := truncateDuration(time.Since(start)) if len(statements) > 0 { - log.Printf("OK %s (%s)\n", filepath.Base(m.Source), finish) + log.Printf("OK %s (%s)\n", filepath.Base(m.Source), finish) } else { - log.Printf("EMPTY %s (%s)\n", filepath.Base(m.Source), finish) + log.Printf("EMPTY %s (%s)\n", filepath.Base(m.Source), finish) } case ".go": if !m.Registered { return fmt.Errorf("ERROR %v: failed to run Go migration: Go functions must be registered and built into a custom binary (see https://github.com/pressly/goose/tree/master/examples/go-migrations)", m.Source) } + if isDryRun { + log.Printf("DRY-RUN %s (%s)\n", filepath.Base(m.Source)) + return nil + } start := time.Now() var empty bool if m.UseTx { @@ -124,9 +150,9 @@ func (m *Migration) run(ctx context.Context, db *sql.DB, direction bool) error { } finish := truncateDuration(time.Since(start)) if !empty { - log.Printf("OK %s (%s)\n", filepath.Base(m.Source), finish) + log.Printf("OK %s (%s)\n", filepath.Base(m.Source), finish) } else { - log.Printf("EMPTY %s (%s)\n", filepath.Base(m.Source), finish) + log.Printf("EMPTY %s (%s)\n", filepath.Base(m.Source), finish) } } return nil diff --git a/redo.go b/redo.go index c485f9f67..c1946f227 100644 --- a/redo.go +++ b/redo.go @@ -34,10 +34,10 @@ func Redo(db *sql.DB, dir string, opts ...OptionsFunc) error { } current.noVersioning = option.noVersioning - if err := current.Down(db); err != nil { + if err := current.Down(db, option.toMigrationOptionsFunc()); err != nil { return err } - if err := current.Up(db); err != nil { + if err := current.Up(db, option.toMigrationOptionsFunc()); err != nil { return err } return nil diff --git a/reset.go b/reset.go index 7be46179c..e8d0c1243 100644 --- a/reset.go +++ b/reset.go @@ -32,7 +32,7 @@ func Reset(db *sql.DB, dir string, opts ...OptionsFunc) error { if !statuses[migration.Version] { continue } - if err = migration.Down(db); err != nil { + if err = migration.Down(db, option.toMigrationOptionsFunc()); err != nil { return fmt.Errorf("failed to db-down: %w", err) } } diff --git a/tests/e2e/dry_run_test.go b/tests/e2e/dry_run_test.go new file mode 100644 index 000000000..854ea3523 --- /dev/null +++ b/tests/e2e/dry_run_test.go @@ -0,0 +1,517 @@ +package e2e + +import ( + "errors" + "testing" + + "github.com/pressly/goose/v3" + "github.com/pressly/goose/v3/internal/check" +) + +func TestMigrateUpWithResetDryRun(t *testing.T) { + t.Parallel() + + db, err := newDockerDB(t) + check.NoError(t, err) + migrations, err := goose.CollectMigrations(migrationsDir, 0, goose.MaxVersion) + check.NoError(t, err) + check.NumberNotZero(t, len(migrations)) + + // Migrate all with a dry run. + err = goose.Up(db, migrationsDir, goose.WithDryRun()) + check.NoError(t, err) + currentVersion, err := goose.GetDBVersion(db) + check.NoError(t, err) + check.Number(t, currentVersion, 0) + + // Validate the db migration version actually matches what goose claims it is + gotVersion, err := getCurrentGooseVersion(db, goose.TableName()) + check.NoError(t, err) + // incorrect database version + check.Number(t, gotVersion, currentVersion) + + // Migrate all, for real this time. + err = goose.Up(db, migrationsDir) + check.NoError(t, err) + currentVersion, err = goose.GetDBVersion(db) + check.NoError(t, err) + check.Number(t, currentVersion, migrations[len(migrations)-1].Version) + + // Validate the db migration version actually matches what goose claims it is + gotVersion, err = getCurrentGooseVersion(db, goose.TableName()) + check.NoError(t, err) + // incorrect database version + check.Number(t, gotVersion, currentVersion) + + // Migrate everything down using Reset with dry run. + err = goose.Reset(db, migrationsDir, goose.WithDryRun()) + check.NoError(t, err) + currentVersion, err = goose.GetDBVersion(db) + check.NoError(t, err) + check.Number(t, currentVersion, migrations[len(migrations)-1].Version) + + // Migrate everything down using Reset, for real this time. + err = goose.Reset(db, migrationsDir) + check.NoError(t, err) + currentVersion, err = goose.GetDBVersion(db) + check.NoError(t, err) + check.Number(t, currentVersion, 0) +} + +func TestMigrateUpWithRedoDryRun(t *testing.T) { + t.Parallel() + + db, err := newDockerDB(t) + check.NoError(t, err) + migrations, err := goose.CollectMigrations(migrationsDir, 0, goose.MaxVersion) + check.NoError(t, err) + + check.NumberNotZero(t, len(migrations)) + startingVersion, err := goose.EnsureDBVersion(db) + check.NoError(t, err) + check.Number(t, startingVersion, 0) + // Migrate all + for _, migration := range migrations { + err = migration.Up(db) + check.NoError(t, err) + currentVersion, err := goose.GetDBVersion(db) + check.NoError(t, err) + check.Number(t, currentVersion, migration.Version) + + // Redo the previous Up migration and re-apply it, with dry run. + err = goose.Redo(db, migrationsDir, goose.WithDryRun()) + check.NoError(t, err) + currentVersion, err = goose.GetDBVersion(db) + check.NoError(t, err) + check.Number(t, currentVersion, migration.Version) + + // Redo the previous Up migration and re-apply it, for real this time. + err = goose.Redo(db, migrationsDir) + check.NoError(t, err) + currentVersion, err = goose.GetDBVersion(db) + check.NoError(t, err) + check.Number(t, currentVersion, migration.Version) + } + // Once everything is tested the version should match the highest testdata version + maxVersion := migrations[len(migrations)-1].Version + currentVersion, err := goose.GetDBVersion(db) + check.NoError(t, err) + check.Number(t, currentVersion, maxVersion) +} + +func TestMigrateUpToWithDryRun(t *testing.T) { + t.Parallel() + + const ( + upToVersion int64 = 2 + ) + db, err := newDockerDB(t) + check.NoError(t, err) + migrations, err := goose.CollectMigrations(migrationsDir, 0, goose.MaxVersion) + check.NoError(t, err) + check.NumberNotZero(t, len(migrations)) + + // Migrate up to the second migration with dry run. + err = goose.UpTo(db, migrationsDir, upToVersion, goose.WithDryRun()) + check.NoError(t, err) + // Fetch the goose version from DB + currentVersion, err := goose.GetDBVersion(db) + check.NoError(t, err) + check.Number(t, currentVersion, 0) + // Validate the version actually matches what goose claims it is + gotVersion, err := getCurrentGooseVersion(db, goose.TableName()) + check.NoError(t, err) + check.Number(t, gotVersion, 0) // incorrect database version + + // Migrate up to the second migration, for real this time. + err = goose.UpTo(db, migrationsDir, upToVersion) + check.NoError(t, err) + // Fetch the goose version from DB + currentVersion, err = goose.GetDBVersion(db) + check.NoError(t, err) + check.Number(t, currentVersion, upToVersion) + // Validate the version actually matches what goose claims it is + gotVersion, err = getCurrentGooseVersion(db, goose.TableName()) + check.NoError(t, err) + check.Number(t, gotVersion, upToVersion) // incorrect database version +} + +func TestMigrateUpByOneWithDryRun(t *testing.T) { + t.Parallel() + + db, err := newDockerDB(t) + check.NoError(t, err) + migrations, err := goose.CollectMigrations(migrationsDir, 0, goose.MaxVersion) + check.NoError(t, err) + check.NumberNotZero(t, len(migrations)) + // Apply all migrations one-by-one, first with dry run enabled, and then + // for real. + var counter int + for { + err := goose.UpByOne(db, migrationsDir, goose.WithDryRun()) + counter++ + if counter > len(migrations) { + if !errors.Is(err, goose.ErrNoNextVersion) { + t.Fatalf("incorrect error: got:%v want:%v", err, goose.ErrNoNextVersion) + } + break + } + check.NoError(t, err) + + err = goose.UpByOne(db, migrationsDir) + check.NoError(t, err) + } + currentVersion, err := goose.GetDBVersion(db) + check.NoError(t, err) + check.Number(t, currentVersion, migrations[len(migrations)-1].Version) + check.Number(t, migrations[counter-2].Version, currentVersion) + // Validate the db migration version actually matches what goose claims it is + gotVersion, err := getCurrentGooseVersion(db, goose.TableName()) + check.NoError(t, err) + check.Number(t, gotVersion, currentVersion) // incorrect database version +} + +func TestNotAllowMissingWithDryRun(t *testing.T) { + t.Parallel() + + // Create and apply first 5 migrations. + db := setupTestDB(t, 5) + + // Developer A and B check out the "main" branch which is currently + // on version 5. Developer A mistakenly creates migration 7 and commits. + // Developer B did not pull the latest changes and commits migration 6. Oops. + + // Developer A - migration 7 (mistakenly applied) + migrations, err := goose.CollectMigrations(migrationsDir, 0, 7) + check.NoError(t, err) + err = migrations[6].Up(db) + check.NoError(t, err) + current, err := goose.GetDBVersion(db) + check.NoError(t, err) + check.Number(t, current, 7) + + // Developer B - migration 6 (missing) and 8 (new) + // This should raise an error. By default goose does not allow missing (out-of-order) + // migrations, which means halt if a missing migration is detected. + err = goose.Up(db, migrationsDir, goose.WithDryRun()) + check.HasError(t, err) + check.Contains(t, err.Error(), "missing migrations") + // Confirm db version is unchanged. + current, err = goose.GetDBVersion(db) + check.NoError(t, err) + check.Number(t, current, 7) +} + +func TestAllowMissingUpWithRedoWithDryRun(t *testing.T) { + t.Parallel() + + // Create and apply first 5 migrations. + db := setupTestDB(t, 5) + + migrations, err := goose.CollectMigrations(migrationsDir, 0, goose.MaxVersion) + check.NoError(t, err) + if len(migrations) == 0 { + t.Fatalf("got zero migrations") + } + + // Migration 7 + { + migrations, err := goose.CollectMigrations(migrationsDir, 0, 7) + check.NoError(t, err) + + // First, apply the migration with dry run. + err = migrations[6].Up(db, goose.MigrationWithDryRun()) + check.NoError(t, err) + current, err := goose.GetDBVersion(db) + check.NoError(t, err) + check.Number(t, current, 5) + + // Then, apply for real. + err = migrations[6].Up(db) + check.NoError(t, err) + current, err = goose.GetDBVersion(db) + check.NoError(t, err) + check.Number(t, current, 7) + + // Redo the previous Up migration and re-apply it. + err = goose.Redo(db, migrationsDir) + check.NoError(t, err) + currentVersion, err := goose.GetDBVersion(db) + check.NoError(t, err) + check.Number(t, currentVersion, migrations[6].Version) + } + // Migration 6 + { + // First, apply with allow missing with dry run. + err = goose.UpByOne(db, migrationsDir, goose.WithAllowMissing(), goose.WithDryRun()) + check.NoError(t, err) + currentVersion, err := goose.GetDBVersion(db) + check.NoError(t, err) + check.Number(t, currentVersion, 7) + + // Then, apply for real. + err = goose.UpByOne(db, migrationsDir, goose.WithAllowMissing()) + check.NoError(t, err) + currentVersion, err = goose.GetDBVersion(db) + check.NoError(t, err) + check.Number(t, currentVersion, 6) + + err = goose.Redo(db, migrationsDir) + check.NoError(t, err) + currentVersion, err = goose.GetDBVersion(db) + check.NoError(t, err) + check.Number(t, currentVersion, 6) + } +} + +func TestNowAllowMissingUpByOneWithDryRun(t *testing.T) { + t.Parallel() + + // Create and apply first 5 migrations. + db := setupTestDB(t, 5) + + /* + Developer A and B simultaneously check out the "main" currently on version 5. + Developer A mistakenly creates migration 7 and commits. + Developer B did not pull the latest changes and commits migration 6. Oops. + + If goose is set to allow missing migrations, then 6 should be applied + after 7. + */ + + // Developer A - migration 7 (mistakenly applied) + { + migrations, err := goose.CollectMigrations(migrationsDir, 0, 7) + check.NoError(t, err) + err = migrations[6].Up(db) + check.NoError(t, err) + current, err := goose.GetDBVersion(db) + check.NoError(t, err) + check.Number(t, current, 7) + } + // Developer B - migration 6 + { + // By default, this should raise an error, even with dry run. + err := goose.UpByOne(db, migrationsDir, goose.WithDryRun()) + // error: found 1 missing migrations + check.HasError(t, err) + check.Contains(t, err.Error(), "missing migrations") + + count, err := getGooseVersionCount(db, goose.TableName()) + check.NoError(t, err) + check.Number(t, count, 6) + + current, err := goose.GetDBVersion(db) + check.NoError(t, err) + // Expecting max(version_id) to be 7 + check.Number(t, current, 7) + } +} + +func TestAllowMissingUpWithResetWithDryRun(t *testing.T) { + t.Parallel() + + // Create and apply first 5 migrations. + db := setupTestDB(t, 5) + + /* + Developer A and B simultaneously check out the "main" currently on version 5. + Developer A mistakenly creates migration 7 and commits. + Developer B did not pull the latest changes and commits migration 6. Oops. + + If goose is set to allow missing migrations, then 6 should be applied + after 7. + */ + + // Developer A - migration 7 (mistakenly applied) + { + migrations, err := goose.CollectMigrations(migrationsDir, 0, 7) + check.NoError(t, err) + err = migrations[6].Up(db) + check.NoError(t, err) + current, err := goose.GetDBVersion(db) + check.NoError(t, err) + check.Number(t, current, 7) + } + // Developer B - migration 6 (missing) and 8 (new) + { + // By default, attempting to apply this migration will raise an error. + // If goose is set to "allow missing" migrations then it should get applied. + err := goose.Up(db, migrationsDir, goose.WithAllowMissing(), goose.WithDryRun()) + // Applying missing migration should return no error when allow-missing=true + check.NoError(t, err) + + // Perform again, for real this time. + err = goose.Up(db, migrationsDir, goose.WithAllowMissing()) + check.NoError(t, err) + + // Avoid hard-coding total and max, instead resolve it from the testdata migrations. + // In other words, we applied 1..5,7,6,8 and this test shouldn't care + // about migration 9 and onwards. + allMigrations, err := goose.CollectMigrations(migrationsDir, 0, goose.MaxVersion) + check.NoError(t, err) + maxVersionID := allMigrations[len(allMigrations)-1].Version + + count, err := getGooseVersionCount(db, goose.TableName()) + check.NoError(t, err) + // Count should be all testdata migrations (all applied) + check.Number(t, count, len(allMigrations)) + + current, err := goose.GetDBVersion(db) + check.NoError(t, err) + // Expecting max(version_id) to be highest version in testdata + check.Number(t, current, maxVersionID) + } + + currentVersion, err := goose.GetDBVersion(db) + check.NoError(t, err) + + // Migrate everything down using Reset, first with dry run and then for + // real. + err = goose.Reset(db, migrationsDir, goose.WithDryRun()) + check.NoError(t, err) + newCurrentVersion, err := goose.GetDBVersion(db) + check.NoError(t, err) + check.Number(t, newCurrentVersion, currentVersion) + + err = goose.Reset(db, migrationsDir) + check.NoError(t, err) + newCurrentVersion, err = goose.GetDBVersion(db) + check.NoError(t, err) + check.Number(t, newCurrentVersion, 0) +} + +func TestAllowMissingUpByOneWithDryRun(t *testing.T) { + t.Parallel() + + // Create and apply first 5 migrations. + db := setupTestDB(t, 5) + + /* + Developer A and B simultaneously check out the "main" currently on version 5. + Developer A mistakenly creates migration 7 and commits. + Developer B did not pull the latest changes and commits migration 6. Oops. + + If goose is set to allow missing migrations, then 6 should be applied + after 7. + */ + + // Developer A - migration 7 (mistakenly applied) + { + migrations, err := goose.CollectMigrations(migrationsDir, 0, 7) + check.NoError(t, err) + err = migrations[6].Up(db) + check.NoError(t, err) + current, err := goose.GetDBVersion(db) + check.NoError(t, err) + check.Number(t, current, 7) + } + // Developer B - migration 6 + { + err := goose.UpByOne(db, migrationsDir, goose.WithAllowMissing()) + check.NoError(t, err) + + count, err := getGooseVersionCount(db, goose.TableName()) + check.NoError(t, err) + // Expecting count of migrations to be 7 + check.Number(t, count, 7) + + current, err := goose.GetDBVersion(db) + check.NoError(t, err) + // Expecting max(version_id) to be 6 + check.Number(t, current, 6) + } + // Developer B - migration 8 + { + // By default, this should raise an error. + err := goose.UpByOne(db, migrationsDir, goose.WithAllowMissing(), goose.WithDryRun()) + check.NoError(t, err) + + err = goose.UpByOne(db, migrationsDir, goose.WithAllowMissing()) + check.NoError(t, err) + + count, err := getGooseVersionCount(db, goose.TableName()) + check.NoError(t, err) + // Expecting count of migrations to be 8 + check.Number(t, count, 8) + + current, err := goose.GetDBVersion(db) + check.NoError(t, err) + // Expecting max(version_id) to be 8 + check.Number(t, current, 8) + } +} + +func TestMigrateAllowMissingDownWithDryRun(t *testing.T) { + t.Parallel() + + const ( + maxVersion = 8 + ) + // Create and apply first 5 migrations. + db := setupTestDB(t, 5) + + // Developer A - migration 7 (mistakenly applied) + { + migrations, err := goose.CollectMigrations(migrationsDir, 0, maxVersion-1) + check.NoError(t, err) + err = migrations[6].Up(db) + check.NoError(t, err) + current, err := goose.GetDBVersion(db) + check.NoError(t, err) + check.Number(t, current, maxVersion-1) + } + // Developer B - migration 6 (missing) and 8 (new) + { + // 6 + err := goose.UpByOne(db, migrationsDir, goose.WithAllowMissing(), goose.WithDryRun()) + check.NoError(t, err) + err = goose.UpByOne(db, migrationsDir, goose.WithAllowMissing()) + check.NoError(t, err) + // 8 + err = goose.UpByOne(db, migrationsDir, goose.WithAllowMissing(), goose.WithDryRun()) + check.NoError(t, err) + err = goose.UpByOne(db, migrationsDir, goose.WithAllowMissing()) + check.NoError(t, err) + + count, err := getGooseVersionCount(db, goose.TableName()) + check.NoError(t, err) + check.Number(t, count, maxVersion) + current, err := goose.GetDBVersion(db) + check.NoError(t, err) + // Expecting max(version_id) to be 8 + check.Number(t, current, maxVersion) + } + // The order in the database is expected to be: + // 1,2,3,4,5,7,6,8 + // So migrating down should be the reverse order: + // 8,6,7,5,4,3,2,1 + // + // Migrate down by one. Expecting 6. + { + err := goose.Down(db, migrationsDir) + check.NoError(t, err) + current, err := goose.GetDBVersion(db) + check.NoError(t, err) + // Expecting max(version) to be 6 + check.Number(t, current, 6) + } + // Migrate down by one. Expecting 7. + { + err := goose.Down(db, migrationsDir) + check.NoError(t, err) + current, err := goose.GetDBVersion(db) + check.NoError(t, err) + // Expecting max(version) to be 7 + check.Number(t, current, 7) + } + // Migrate down by one. Expecting 5. + { + err := goose.Down(db, migrationsDir) + check.NoError(t, err) + current, err := goose.GetDBVersion(db) + check.NoError(t, err) + // Expecting max(version) to be 5 + check.Number(t, current, 5) + } +} diff --git a/up.go b/up.go index bc8ddc789..1532c8b86 100644 --- a/up.go +++ b/up.go @@ -13,6 +13,16 @@ type options struct { allowMissing bool applyUpByOne bool noVersioning bool + isDryRun bool +} + +// toMigrationOptionsFunc returns the migrations options function for the given +// options. +func (o options) toMigrationOptionsFunc() MigrationOptionsFunc { + migrationOptFunc := func(migrationOpt *migrationOptions) { + migrationOpt.isDryRun = o.isDryRun + } + return migrationOptFunc } type OptionsFunc func(o *options) @@ -29,6 +39,10 @@ func WithNoColor(b bool) OptionsFunc { return func(o *options) { noColor = b } } +func WithDryRun() OptionsFunc { + return func(o *options) { o.isDryRun = true } +} + func withApplyUpByOne() OptionsFunc { return func(o *options) { o.applyUpByOne = true } } @@ -54,7 +68,7 @@ func UpTo(db *sql.DB, dir string, version int64, opts ...OptionsFunc) error { // migration over and over. version = foundMigrations[0].Version } - return upToNoVersioning(db, foundMigrations, version) + return upToNoVersioning(db, foundMigrations, version, option.isDryRun) } if _, err := EnsureDBVersion(db); err != nil { @@ -90,13 +104,11 @@ func UpTo(db *sql.DB, dir string, version int64, opts ...OptionsFunc) error { ) } - var current int64 + current, err := GetDBVersion(db) + if err != nil { + return err + } for { - var err error - current, err = GetDBVersion(db) - if err != nil { - return err - } next, err := foundMigrations.Next(current) if err != nil { if errors.Is(err, ErrNoNextVersion) { @@ -104,12 +116,13 @@ func UpTo(db *sql.DB, dir string, version int64, opts ...OptionsFunc) error { } return fmt.Errorf("failed to find next migration: %v", err) } - if err := next.Up(db); err != nil { + if err := next.Up(db, option.toMigrationOptionsFunc()); err != nil { return err } if option.applyUpByOne { return nil } + current = next.Version } // At this point there are no more migrations to apply. But we need to maintain // the following behaviour: @@ -124,14 +137,17 @@ func UpTo(db *sql.DB, dir string, version int64, opts ...OptionsFunc) error { // upToNoVersioning applies up migrations up to, and including, the // target version. -func upToNoVersioning(db *sql.DB, migrations Migrations, version int64) error { +func upToNoVersioning(db *sql.DB, migrations Migrations, version int64, isDryRun bool) error { var finalVersion int64 for _, current := range migrations { if current.Version > version { break } current.noVersioning = true - if err := current.Up(db); err != nil { + migrationOptFunc := func(opt *migrationOptions) { + opt.isDryRun = isDryRun + } + if err := current.Up(db, migrationOptFunc); err != nil { return err } finalVersion = current.Version @@ -154,27 +170,14 @@ func upWithMissing( // Apply all missing migrations first. for _, missing := range missingMigrations { - if err := missing.Up(db); err != nil { + if err := missing.Up(db, option.toMigrationOptionsFunc()); err != nil { return err } // Apply one migration and return early. if option.applyUpByOne { return nil } - // TODO(mf): do we need this check? It's a bit redundant, but we may - // want to keep it as a safe-guard. Maybe we should instead have - // the underlying query (if possible) return the current version as - // part of the same transaction. - current, err := GetDBVersion(db) - if err != nil { - return err - } - if current == missing.Version { - lookupApplied[missing.Version] = true - continue - } - return fmt.Errorf("error: missing migration:%d does not match current db version:%d", - current, missing.Version) + lookupApplied[missing.Version] = true } // We can no longer rely on the database version_id to be sequential because @@ -189,7 +192,7 @@ func upWithMissing( if lookupApplied[found.Version] { continue } - if err := found.Up(db); err != nil { + if err := found.Up(db, option.toMigrationOptionsFunc()); err != nil { return err } if option.applyUpByOne {