fix: apply pending migrations without version arithmetic
This commit was merged in pull request #1.
This commit is contained in:
1 parent
8b5364d898
commit
4ca353dd7e
5 files changed
+430
-21
No files matched your search
@@ -0,0 +1,334 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"gitea.auvem.com/go-toolkit/app"
|
||||
"github.com/pressly/goose/v3"
|
||||
"github.com/stretchr/testify/require"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// These tests own the package globals sequentially and use a fresh in-memory DB.
|
||||
func operationsFixture(t *testing.T, versions []int64) (*goose.Provider, *sql.DB, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
require.NoError(t, err)
|
||||
db.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { require.NoError(t, db.Close()) })
|
||||
files := fstest.MapFS{}
|
||||
for _, version := range versions {
|
||||
name := fmt.Sprintf("%05d_migration.sql", version)
|
||||
files[name] = &fstest.MapFile{Data: []byte(fmt.Sprintf("-- +goose Up\nCREATE TABLE migration_%d (id INTEGER);\n-- +goose Down\nDROP TABLE migration_%d;\n", version, version))}
|
||||
}
|
||||
p, err := goose.NewProvider(goose.DialectSQLite3, db, files)
|
||||
require.NoError(t, err)
|
||||
logs := installOperationsProvider(t, p)
|
||||
return p, db, logs
|
||||
}
|
||||
|
||||
func installOperationsProvider(t *testing.T, p *goose.Provider) *bytes.Buffer {
|
||||
t.Helper()
|
||||
oldProvider, oldModule, oldEnabled := migrationProvider, migrationsModule, autoMigrateEnabled
|
||||
t.Cleanup(func() {
|
||||
migrationProvider, migrationsModule, autoMigrateEnabled = oldProvider, oldModule, oldEnabled
|
||||
})
|
||||
logs := new(bytes.Buffer)
|
||||
mod := app.NewModule(ModuleMigrationsName, app.ModuleOpts{})
|
||||
lifecycle := app.NewLifecycle(mod).WithLogger(slog.New(slog.NewJSONHandler(logs, nil)))
|
||||
require.NoError(t, lifecycle.Setup())
|
||||
migrationProvider, migrationsModule, autoMigrateEnabled = p, mod, true
|
||||
logs.Reset()
|
||||
return logs
|
||||
}
|
||||
|
||||
var identifierHistories = []struct {
|
||||
name string
|
||||
versions []int64
|
||||
}{
|
||||
{"contiguous", []int64{1, 2, 3}},
|
||||
{"small_gaps", []int64{1, 4, 8}},
|
||||
{"sequential_to_timestamp", []int64{28, 29, 20260929235340}},
|
||||
{"timestamps", []int64{20260929235340, 20261001010203, 20261005120000}},
|
||||
{"mixed", []int64{1, 2, 29, 20260929235340, 20261001010203}},
|
||||
}
|
||||
|
||||
func seedHistory(t *testing.T, p *goose.Provider, count int) {
|
||||
t.Helper()
|
||||
for range count {
|
||||
_, err := p.UpByOne(t.Context())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertHistory(t *testing.T, p *goose.Provider, db *sql.DB, versions []int64, applied int) {
|
||||
t.Helper()
|
||||
current, target, err := p.GetVersions(t.Context())
|
||||
require.NoError(t, err)
|
||||
var expected int64
|
||||
if applied > 0 {
|
||||
expected = versions[applied-1]
|
||||
}
|
||||
require.Equal(t, expected, current)
|
||||
require.Equal(t, versions[len(versions)-1], target)
|
||||
var actual int
|
||||
require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM goose_db_version WHERE version_id > 0 AND is_applied = 1").Scan(&actual))
|
||||
require.Equal(t, applied, actual)
|
||||
}
|
||||
|
||||
func TestApplyPendingMigrationsIdentifiersAndCaps(t *testing.T) {
|
||||
for _, history := range identifierHistories {
|
||||
for _, initial := range []int{0, 1, len(history.versions)} {
|
||||
for _, cap := range []int64{0, -1, -7, 1, 2, 100} {
|
||||
t.Run(fmt.Sprintf("%s/initial_%d/cap_%d", history.name, initial, cap), func(t *testing.T) {
|
||||
p, db, _ := operationsFixture(t, history.versions)
|
||||
seedHistory(t, p, initial)
|
||||
want := len(history.versions) - initial
|
||||
if cap >= 0 && cap < int64(want) {
|
||||
want = int(cap)
|
||||
}
|
||||
count, err := ApplyPendingMigrations(t.Context(), cap)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(want), count)
|
||||
assertHistory(t, p, db, history.versions, initial+want)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMigrateIdentifiersAndEnabled(t *testing.T) {
|
||||
for _, history := range identifierHistories {
|
||||
for _, initial := range []int{0, 1, len(history.versions)} {
|
||||
for _, enabled := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("%s/initial_%d/enabled_%t", history.name, initial, enabled), func(t *testing.T) {
|
||||
p, db, logs := operationsFixture(t, history.versions)
|
||||
seedHistory(t, p, initial)
|
||||
autoMigrateEnabled = enabled
|
||||
err := AutoMigrate(t.Context())
|
||||
if !enabled && initial < len(history.versions) {
|
||||
require.ErrorIs(t, err, ErrPendingMigrations)
|
||||
assertHistory(t, p, db, history.versions, initial)
|
||||
record := logWithMessage(t, logs, "Pending migrations detected, but auto-migration is disabled. Run `migrate up` to apply them.")
|
||||
require.Equal(t, "ERROR", record["level"])
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assertHistory(t, p, db, history.versions, len(history.versions))
|
||||
if initial == len(history.versions) {
|
||||
record := logWithMessage(t, logs, "No pending migrations")
|
||||
require.Equal(t, strconv.FormatInt(history.versions[initial-1], 10), record["version"].(json.Number).String())
|
||||
} else {
|
||||
record := logWithMessage(t, logs, "Applied pending migrations")
|
||||
require.Equal(t, strconv.Itoa(len(history.versions)-initial), record["appliedCount"].(json.Number).String())
|
||||
require.Equal(t, strconv.FormatInt(history.versions[len(history.versions)-1], 10), record["current"].(json.Number).String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func logRecords(t *testing.T, logs *bytes.Buffer) []map[string]any {
|
||||
t.Helper()
|
||||
decoder := json.NewDecoder(bytes.NewReader(logs.Bytes()))
|
||||
decoder.UseNumber()
|
||||
var records []map[string]any
|
||||
for {
|
||||
var record map[string]any
|
||||
err := decoder.Decode(&record)
|
||||
if errors.Is(err, io.EOF) {
|
||||
return records
|
||||
}
|
||||
require.NoError(t, err)
|
||||
records = append(records, record)
|
||||
}
|
||||
}
|
||||
|
||||
func logWithMessage(t *testing.T, logs *bytes.Buffer, message string) map[string]any {
|
||||
t.Helper()
|
||||
for _, record := range logRecords(t, logs) {
|
||||
if record["msg"] == message {
|
||||
return record
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing log %q in %s", message, logs)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAutoMigrateLogsRealVersionsAndEmptyMigration(t *testing.T) {
|
||||
p, db, logs := operationsFixture(t, []int64{29})
|
||||
seedHistory(t, p, 1)
|
||||
files := fstest.MapFS{
|
||||
"00029_migration.sql": {Data: []byte("-- +goose Up\nSELECT 1;\n-- +goose Down\nSELECT 1;\n")},
|
||||
"20260929235340_migration.sql": {Data: []byte("-- +goose Up\nSELECT 1;\n-- +goose Down\nSELECT 1;\n")},
|
||||
"20261001010203_empty.sql": {Data: []byte("-- +goose Up\n-- +goose Down\n")},
|
||||
}
|
||||
var err error
|
||||
migrationProvider, err = goose.NewProvider(goose.DialectSQLite3, db, files)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, AutoMigrate(t.Context()))
|
||||
status := logWithMessage(t, logs, "Pending migrations detected, applying them...")
|
||||
require.Equal(t, json.Number("29"), status["current"])
|
||||
require.Equal(t, json.Number("20261001010203"), status["target"])
|
||||
for _, expected := range []struct{ message, version, source, level string }{
|
||||
{"Applied migration", "20260929235340", "20260929235340_migration.sql", "INFO"},
|
||||
{"Applied empty migration", "20261001010203", "20261001010203_empty.sql", "WARN"},
|
||||
} {
|
||||
record := logWithMessage(t, logs, expected.message)
|
||||
require.Equal(t, json.Number(expected.version), record["version"])
|
||||
require.Equal(t, expected.source, record["source"])
|
||||
require.Equal(t, "up", record["dir"])
|
||||
require.Equal(t, expected.level, record["level"])
|
||||
require.Contains(t, record, "duration")
|
||||
}
|
||||
summary := logWithMessage(t, logs, "Applied pending migrations")
|
||||
require.Equal(t, json.Number("20261001010203"), summary["current"])
|
||||
require.Equal(t, json.Number("2"), summary["appliedCount"])
|
||||
require.Contains(t, summary, "duration")
|
||||
}
|
||||
|
||||
func TestMigrationFailureCountsAndLogs(t *testing.T) {
|
||||
for _, auto := range []bool{false, true} {
|
||||
for _, cause := range []error{errors.New("migration failed"), fmt.Errorf("migration failed: %w", goose.ErrNoNextVersion)} {
|
||||
t.Run(fmt.Sprintf("auto_%t/%s", auto, cause), func(t *testing.T) {
|
||||
_, db, logs := operationsFixture(t, []int64{29})
|
||||
versions := []int64{29, 20260929235340, 20261001010203}
|
||||
p, err := goose.NewProvider(goose.DialectSQLite3, db, nil, goose.WithGoMigrations(
|
||||
goose.NewGoMigration(versions[0], &goose.GoFunc{}, nil),
|
||||
goose.NewGoMigration(versions[1], &goose.GoFunc{}, nil),
|
||||
goose.NewGoMigration(versions[2], &goose.GoFunc{RunTx: func(context.Context, *sql.Tx) error { return cause }}, nil),
|
||||
))
|
||||
require.NoError(t, err)
|
||||
migrationProvider = p
|
||||
seedHistory(t, p, 1)
|
||||
if auto {
|
||||
err = AutoMigrate(t.Context())
|
||||
} else {
|
||||
var count int64
|
||||
count, err = ApplyPendingMigrations(t.Context(), -1)
|
||||
require.Equal(t, int64(1), count)
|
||||
}
|
||||
require.ErrorIs(t, err, cause)
|
||||
var partial *goose.PartialError
|
||||
require.ErrorAs(t, err, &partial)
|
||||
assertHistory(t, p, db, versions, 2)
|
||||
failure := logWithMessage(t, logs, "Couldn't apply migration")
|
||||
require.Equal(t, "ERROR", failure["level"])
|
||||
require.Equal(t, json.Number("20261001010203"), failure["version"])
|
||||
require.Equal(t, "up", failure["dir"])
|
||||
require.Contains(t, failure, "source")
|
||||
require.Contains(t, failure, "duration")
|
||||
require.Contains(t, failure, "err")
|
||||
if auto {
|
||||
summary := logWithMessage(t, logs, "Couldn't apply pending migrations")
|
||||
require.Equal(t, json.Number("20260929235340"), summary["current"])
|
||||
require.Equal(t, json.Number("20261001010203"), summary["target"])
|
||||
require.Equal(t, json.Number("1"), summary["appliedCount"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationsProviderAndLookupErrors(t *testing.T) {
|
||||
p, db, logs := operationsFixture(t, []int64{29, 20260929235340})
|
||||
migrationProvider = nil
|
||||
count, err := ApplyPendingMigrations(t.Context(), -1)
|
||||
require.Zero(t, count)
|
||||
require.ErrorIs(t, err, ErrNotInitialized)
|
||||
require.ErrorIs(t, AutoMigrate(t.Context()), ErrNotInitialized)
|
||||
migrationProvider = p
|
||||
require.NoError(t, db.Close())
|
||||
count, err = ApplyPendingMigrations(t.Context(), 0)
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, count)
|
||||
for _, cap := range []int64{-1, 1} {
|
||||
count, err = ApplyPendingMigrations(t.Context(), cap)
|
||||
require.Error(t, err)
|
||||
require.Zero(t, count)
|
||||
}
|
||||
require.Error(t, AutoMigrate(t.Context()))
|
||||
failure := logWithMessage(t, logs, "Couldn't check for pending migrations")
|
||||
require.Equal(t, "ERROR", failure["level"])
|
||||
require.Contains(t, failure, "err")
|
||||
}
|
||||
|
||||
func TestOperationsPropagateCancellation(t *testing.T) {
|
||||
p, db, _ := operationsFixture(t, []int64{29, 20260929235340})
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
count, err := ApplyPendingMigrations(ctx, -1)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
require.Zero(t, count)
|
||||
require.ErrorIs(t, AutoMigrate(ctx), context.Canceled)
|
||||
assertHistory(t, p, db, []int64{29, 20260929235340}, 0)
|
||||
}
|
||||
|
||||
type stepResult struct {
|
||||
result *goose.MigrationResult
|
||||
err error
|
||||
}
|
||||
|
||||
type scriptedStepper struct {
|
||||
steps []stepResult
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *scriptedStepper) UpByOne(context.Context) (*goose.MigrationResult, error) {
|
||||
if s.calls >= len(s.steps) {
|
||||
panic("unexpected UpByOne call")
|
||||
}
|
||||
step := s.steps[s.calls]
|
||||
s.calls++
|
||||
return step.result, step.err
|
||||
}
|
||||
|
||||
func TestPendingApplicationExhaustionAndErrors(t *testing.T) {
|
||||
failure := errors.New("provider failed")
|
||||
resultFailure := errors.New("result failed")
|
||||
good := &goose.MigrationResult{Source: &goose.Source{Version: 20260929235340, Path: "migration.sql"}, Direction: "up", Duration: time.Millisecond}
|
||||
bad := &goose.MigrationResult{Source: &goose.Source{Version: 20261001010203, Path: "bad.sql"}, Direction: "up", Error: resultFailure}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
cap int64
|
||||
steps []stepResult
|
||||
wantCount, wantVersion int64
|
||||
wantErr error
|
||||
}{
|
||||
{"zero", 0, nil, 0, 0, nil},
|
||||
{"positive_cap", 1, []stepResult{{good, nil}}, 1, good.Source.Version, nil},
|
||||
{"wrapped_exhaustion_unlimited", -1, []stepResult{{good, nil}, {nil, fmt.Errorf("no pending: %w", goose.ErrNoNextVersion)}}, 1, good.Source.Version, nil},
|
||||
{"wrapped_exhaustion_cap", 100, []stepResult{{good, nil}, {nil, fmt.Errorf("no pending: %w", goose.ErrNoNextVersion)}}, 1, good.Source.Version, nil},
|
||||
{"already_current", -1, []stepResult{{nil, goose.ErrNoNextVersion}}, 0, 0, nil},
|
||||
{"provider_error", -1, []stepResult{{good, nil}, {nil, failure}}, 1, good.Source.Version, failure},
|
||||
{"result_error", -1, []stepResult{{good, nil}, {bad, nil}}, 1, good.Source.Version, resultFailure},
|
||||
{"error_with_result", -1, []stepResult{{good, failure}}, 0, 0, failure},
|
||||
{"nil_result", -1, []stepResult{{nil, nil}}, 0, 0, nil},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
stepper := &scriptedStepper{steps: test.steps}
|
||||
logger := slog.New(slog.NewJSONHandler(io.Discard, nil))
|
||||
count, version, err := applyPendingMigrations(t.Context(), stepper, logger, test.cap)
|
||||
if test.wantErr != nil {
|
||||
require.ErrorIs(t, err, test.wantErr)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.Equal(t, test.wantCount, count)
|
||||
require.Equal(t, test.wantVersion, version)
|
||||
require.Equal(t, len(test.steps), stepper.calls)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user