From d912b475b599ce3863655c0964a73f0dcf845802 Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Tue, 27 Oct 2026 12:05:21 -0700 Subject: [PATCH] test(migrate): verify canonical SQL-only version ordering --- migrate/README.md | 18 +++++--- migrate/migrate.go | 5 ++- migrate/migrate_test.go | 52 ++++++++++++++++++++++- migrate/testdata/numeric/00007_events.sql | 6 +++ migrate/testdata/numeric/00010_event.sql | 5 +++ 5 files changed, 77 insertions(+), 9 deletions(-) create mode 100644 migrate/testdata/numeric/00007_events.sql create mode 100644 migrate/testdata/numeric/00010_event.sql diff --git a/migrate/README.md b/migrate/README.md index a9399d1..1a6faf5 100644 --- a/migrate/README.md +++ b/migrate/README.md @@ -40,11 +40,19 @@ not defer it when sharing the pool with the application. Use `"."` for SQL files at the embedded root. Files in the selected directory are discovered directly, without recursively searching subdirectories. -Use unique positive versions in filenames such as -`20261027114500_create_users.sql`; sequential versions such as `00007_users.sql` -also work. Never derive a version from a file count or add one to the current -version. Keep applied migrations immutable and ship the complete history. -Goose rejects newly introduced migrations below the current version by default. +Use unique positive versions and Goose's migration creator naming convention: +zero-padded sequential names such as `00007_users.sql` and `00010_events.sql`, or +fixed-width timestamps such as `20261027114500_create_users.sql`. Filenames must +sort lexically in numeric version order, including when mixing naming schemes; +choose padding wide enough for the complete history. Goose v3.29.0 preserves +lexical source order for SQL-only providers, so arbitrary unpadded names such as +`7_users.sql` and `10_events.sql` are unsupported by this house convention: they +can produce incorrect latest versions and source/status ordering. This package +does not rename or normalize migration files. + +Never derive a version from a file count or add one to the current version. +Keep applied migrations immutable and ship the complete history. Goose rejects +newly introduced migrations below the current version by default. ```sql -- +goose Up diff --git a/migrate/migrate.go b/migrate/migrate.go index a61e2fb..df730d5 100644 --- a/migrate/migrate.go +++ b/migrate/migrate.go @@ -23,8 +23,9 @@ import ( // affinity (transaction-pooling proxies cannot safely hold session locks). // // Use the returned Provider's Up, UpTo, DownTo, Status, GetDBVersion, GetVersions, -// and HasPending methods directly. Versions may be sequential or timestamps; -// Goose orders and tracks them. Global Go migration registration is ignored. +// and HasPending methods directly. Filenames must sort lexically in numeric +// version order: use zero-padded sequential or fixed-width timestamp prefixes, +// as produced by Goose's migration creator. Global Go registration is ignored. func New(db *sql.DB, dialect goose.Dialect, files embed.FS, dir string, logger *slog.Logger, migrations ...*goose.Migration) (*goose.Provider, error) { for i, migration := range migrations { if migration == nil { diff --git a/migrate/migrate_test.go b/migrate/migrate_test.go index 670a155..9f77f60 100644 --- a/migrate/migrate_test.go +++ b/migrate/migrate_test.go @@ -34,7 +34,7 @@ func TestSQLite(t *testing.T) { name string run func(*testing.T, *sql.DB, goose.Dialect) }{ - {"flow", testFlow}, {"failure", testFailure}, + {"flow", testFlow}, {"sql-only", testSQLOnly}, {"failure", testFailure}, } { t.Run(test.name, func(t *testing.T) { db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "migrate.db")) @@ -56,7 +56,7 @@ func TestPostgres(t *testing.T) { name string run func(*testing.T, *sql.DB, goose.Dialect) }{ - {"flow", testFlow}, {"failure", testFailure}, + {"flow", testFlow}, {"sql-only", testSQLOnly}, {"failure", testFailure}, } { t.Run(test.name, func(t *testing.T) { test.run(t, postgresDatabase(t)(), goose.DialectPostgres) }) } @@ -133,6 +133,47 @@ func testFlow(t *testing.T, db *sql.DB, dialect goose.Dialect) { } } +func testSQLOnly(t *testing.T, db *sql.DB, dialect goose.Dialect) { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + for _, test := range []struct { + dir string + versions []int64 + }{ + {"testdata/numeric", []int64{7, 10}}, + {"testdata/migrations", []int64{7, 20261026093000, 20261027114500}}, + } { + p := newProvider(t, db, dialect, test.dir) + assertState(t, ctx, p, 0, test.versions, 0) + results, err := p.UpTo(ctx, test.versions[1]-1) + if err != nil { + t.Fatal(err) + } + assertResults(t, results, test.versions[:1]) + assertState(t, ctx, p, test.versions[0], test.versions, 1) + results, err = p.Up(ctx) + if err != nil { + t.Fatal(err) + } + assertResults(t, results, test.versions[1:]) + assertState(t, ctx, p, test.versions[len(test.versions)-1], test.versions, len(test.versions)) + assertEvents(t, ctx, db, test.versions) + results, err = p.DownTo(ctx, test.versions[1]-1) + if err != nil { + t.Fatal(err) + } + wantDown := slices.Clone(test.versions[1:]) + slices.Reverse(wantDown) + assertResults(t, results, wantDown) + assertState(t, ctx, p, test.versions[0], test.versions, 1) + if _, err := p.DownTo(ctx, 0); err != nil { + t.Fatal(err) + } + assertState(t, ctx, p, 0, test.versions, 0) + } +} + func testFailure(t *testing.T, db *sql.DB, dialect goose.Dialect) { t.Helper() ctx, cancel := context.WithTimeout(t.Context(), time.Minute) @@ -165,6 +206,13 @@ func newProvider(t *testing.T, db *sql.DB, dialect goose.Dialect, dir string, mi func assertState(t *testing.T, ctx context.Context, p *goose.Provider, current int64, versions []int64, applied int) { t.Helper() + var sources []int64 + for _, source := range p.ListSources() { + sources = append(sources, source.Version) + } + if !slices.Equal(sources, versions) { + t.Fatalf("ListSources = %v; want %v", sources, versions) + } got, err := p.GetDBVersion(ctx) if err != nil || got != current { t.Fatalf("GetDBVersion = %d, %v; want %d", got, err, current) diff --git a/migrate/testdata/numeric/00007_events.sql b/migrate/testdata/numeric/00007_events.sql new file mode 100644 index 0000000..ff1c813 --- /dev/null +++ b/migrate/testdata/numeric/00007_events.sql @@ -0,0 +1,6 @@ +-- +goose Up +CREATE TABLE migration_events (version BIGINT PRIMARY KEY); +INSERT INTO migration_events (version) VALUES (7); + +-- +goose Down +DROP TABLE migration_events; diff --git a/migrate/testdata/numeric/00010_event.sql b/migrate/testdata/numeric/00010_event.sql new file mode 100644 index 0000000..2b486ef --- /dev/null +++ b/migrate/testdata/numeric/00010_event.sql @@ -0,0 +1,5 @@ +-- +goose Up +INSERT INTO migration_events (version) VALUES (10); + +-- +goose Down +DELETE FROM migration_events WHERE version = 10;