Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions migrations/embed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Package migrations carries restcol's SQL migrations and exposes them as an
// embed.FS so a consuming module can apply exactly the migrations that shipped
// with the version it pins.
//
// WHY THIS FILE EXISTS. These .sql files are plain files in the module, and
// Go's embed cannot reach into a dependency — a consumer that imports restcol
// can see the package but not the files beside it. So a downstream service
// wanting to run these migrations had only one option: copy them. That is what
// FootprintAI/grandturks#987 did, into `deploy/demo/restcol-migrations/` behind
// a drift test, and it was the right stopgap for a deployment that was broken.
//
// But a copy of a file whose original is versioned elsewhere drifts, and the
// drift is silent in the direction that matters: the copy keeps working while
// the pinned version moves underneath it. Exporting the FS makes the pinned
// version and the applied migrations the same fact by construction, which is
// what FootprintAI/grandturks#1004 asked for.
//
// Consumers apply these with golang-migrate's iofs source driver:
//
// src, err := iofs.New(migrations.EmbeddedMigrations, ".")
// m, err := migrate.NewWithSourceInstance("iofs", src, databaseURL)
// err = m.Up()
//
// Files follow golang-migrate's naming convention,
// `<version>_<slug>.{up,down}.sql`; see README.md in this directory.
package migrations

import "embed"

// EmbeddedMigrations holds every migration in this directory, in both
// directions.
//
// The pattern is deliberately `*.sql` at this level and nothing deeper: these
// migrations are a flat, ordered sequence, and a subdirectory would be a
// second ordering that golang-migrate does not read. TestEmbeddedMigrations-
// CoverEveryFileOnDisk fails if a .sql file ever appears outside this pattern,
// so an accidental subdirectory is caught rather than silently skipped.
//
//go:embed *.sql
var EmbeddedMigrations embed.FS
121 changes: 121 additions & 0 deletions migrations/embed_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package migrations

import (
"io/fs"
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// These migrations are consumed by OTHER modules — grandturks pins restcol and
// applies exactly the migrations that shipped with the version it pinned
// (FootprintAI/grandturks#1004). Go's embed cannot reach into a dependency, so
// without EmbeddedMigrations a consumer's only option is to copy the files,
// which is what grandturks#987 had to do. A copy of a file versioned elsewhere
// drifts; that is the whole reason this exists.
//
// The tests below are therefore about COVERAGE, not about the SQL: every file
// in this directory has to be reachable through the embed, or a consumer
// silently applies a subset and believes it is current.

// readDiskSQL returns every .sql file in this directory, keyed by path
// relative to it — including any in subdirectories, which is exactly the case
// a bare `//go:embed *.sql` would miss.
func readDiskSQL(t *testing.T) map[string][]byte {
t.Helper()
found := map[string][]byte{}
require.NoError(t, filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(path, ".sql") {
return nil
}
raw, readErr := os.ReadFile(path)
if readErr != nil {
return readErr
}
found[filepath.ToSlash(path)] = raw
return nil
}))
return found
}

func readEmbeddedSQL(t *testing.T) map[string][]byte {
t.Helper()
found := map[string][]byte{}
require.NoError(t, fs.WalkDir(EmbeddedMigrations, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(path, ".sql") {
return nil
}
raw, readErr := EmbeddedMigrations.ReadFile(path)
if readErr != nil {
return readErr
}
found[path] = raw
return nil
}))
return found
}

// TestEmbeddedMigrationsCoverEveryFileOnDisk is the one that matters. A
// migration that exists on disk but not in the embed is invisible to every
// consumer, and nothing else in the build would say so.
func TestEmbeddedMigrationsCoverEveryFileOnDisk(t *testing.T) {
disk := readDiskSQL(t)
embedded := readEmbeddedSQL(t)

require.NotEmpty(t, disk, "no .sql files found — this test is not looking where it thinks it is")

for name, want := range disk {
got, ok := embedded[name]
if !ok {
t.Errorf("%s is on disk but not in EmbeddedMigrations.\n"+
"A consumer pinning this module would apply the other migrations and believe "+
"its schema is current. Check the //go:embed pattern covers it.", name)
continue
}
assert.Equal(t, string(want), string(got), "%s differs between disk and the embed", name)
}

for name := range embedded {
assert.Contains(t, disk, name, "%s is embedded but not on disk", name)
}
}

// golang-migrate resolves a version by finding both directions. An up without
// a down is not a partial feature — it is a migration that cannot be rolled
// back, discovered at the moment someone needs to roll it back.
func TestEveryMigrationHasBothDirections(t *testing.T) {
embedded := readEmbeddedSQL(t)

for name := range embedded {
var counterpart string
switch {
case strings.HasSuffix(name, ".up.sql"):
counterpart = strings.TrimSuffix(name, ".up.sql") + ".down.sql"
case strings.HasSuffix(name, ".down.sql"):
counterpart = strings.TrimSuffix(name, ".down.sql") + ".up.sql"
default:
t.Errorf("%s is neither .up.sql nor .down.sql — golang-migrate will not see it as a migration", name)
continue
}
assert.Contains(t, embedded, counterpart, "%s has no %s", name, counterpart)
}
}

// An empty migration file applies cleanly and does nothing, so the version
// table advances and the schema does not. That is worse than a failure.
func TestNoEmbeddedMigrationIsEmpty(t *testing.T) {
for name, content := range readEmbeddedSQL(t) {
assert.NotEmpty(t, strings.TrimSpace(string(content)),
"%s is empty: it would advance the schema version without changing the schema", name)
}
}
Loading