Skip to content
Draft
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
4 changes: 2 additions & 2 deletions internal/db/declarative/debug.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@ func CollectMigrationsList(fsys afero.Fs) []string {
if err != nil {
return nil
}
// Strip directory prefix to return just filenames
// Strip directory prefix to return display names
for i, m := range migrations {
migrations[i] = filepath.Base(m)
migrations[i] = migration.MigrationName(m)
}
return migrations
}
4 changes: 1 addition & 3 deletions internal/db/push/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"context"
"fmt"
"os"
"path/filepath"

"github.com/go-errors/errors"
"github.com/jackc/pgconn"
Expand Down Expand Up @@ -118,8 +117,7 @@ func Run(ctx context.Context, dryRun, ignoreVersionMismatch bool, includeRoles,

func confirmPushAll(pending []string) (msg string) {
for _, path := range pending {
filename := filepath.Base(path)
msg += fmt.Sprintf(" • %s\n", utils.Bold(filename))
msg += fmt.Sprintf(" • %s\n", utils.Bold(migration.MigrationName(path)))
}
return msg
}
Expand Down
19 changes: 16 additions & 3 deletions internal/migration/repair/repair.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,15 +88,28 @@ func UpdateMigrationTable(ctx context.Context, conn *pgx.Conn, version []string,
}

func GetMigrationFile(version string, fsys afero.Fs) (string, error) {
// Try flat file first: version_*.sql
path := filepath.Join(utils.MigrationsDir, version+"_*.sql")
matches, err := afero.Glob(fsys, path)
if err != nil {
return "", errors.Errorf("failed to glob migration files: %w", err)
}
if len(matches) == 0 {
return "", errors.Errorf("glob %s: %w", path, os.ErrNotExist)
if len(matches) > 0 {
return matches[0], nil
}
return matches[0], nil
// Try folder-based migration: version_*/*.sql
dirPath := filepath.Join(utils.MigrationsDir, version+"_*", "*.sql")
dirMatches, err := afero.Glob(fsys, dirPath)
if err != nil {
return "", errors.Errorf("failed to glob migration directories: %w", err)
}
if len(dirMatches) == 1 {
return dirMatches[0], nil
}
if len(dirMatches) > 1 {
return "", errors.Errorf("multiple .sql files found for version %s", version)
}
return "", errors.Errorf("no migration found for version %s: %w", version, os.ErrNotExist)
}

func NewMigrationFromVersion(version string, fsys afero.Fs) (*migration.MigrationFile, error) {
Expand Down
56 changes: 56 additions & 0 deletions internal/migration/repair/repair_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,23 @@ func TestRepairCommand(t *testing.T) {
assert.NoError(t, err)
})

t.Run("applies folder-based migration version", func(t *testing.T) {
// Setup in-memory fs
fsys := afero.NewMemMapFs()
sqlPath := filepath.Join(utils.MigrationsDir, "20242409125510_premium_mister_fear", "schema.sql")
require.NoError(t, afero.WriteFile(fsys, sqlPath, []byte("select 1"), 0644))
// Setup mock postgres
conn := pgtest.NewConn()
defer conn.Close(t)
helper.MockMigrationHistory(conn).
Query(migration.UPSERT_MIGRATION_VERSION, "20242409125510", "premium_mister_fear", []string{"select 1"}).
Reply("INSERT 0 1")
// Run test
err := Run(context.Background(), dbConfig, []string{"20242409125510"}, Applied, fsys, conn.Intercept)
// Check error
assert.NoError(t, err)
})

t.Run("throws error on invalid version", func(t *testing.T) {
// Setup in-memory fs
fsys := afero.NewMemMapFs()
Expand Down Expand Up @@ -97,6 +114,45 @@ func TestRepairCommand(t *testing.T) {
})
}

func TestGetMigrationFile(t *testing.T) {
t.Run("finds flat migration file", func(t *testing.T) {
fsys := afero.NewMemMapFs()
path := filepath.Join(utils.MigrationsDir, "0_test.sql")
require.NoError(t, afero.WriteFile(fsys, path, []byte("select 1"), 0644))
// Run test
result, err := GetMigrationFile("0", fsys)
assert.NoError(t, err)
assert.Equal(t, path, result)
})

t.Run("finds folder-based migration file", func(t *testing.T) {
fsys := afero.NewMemMapFs()
sqlPath := filepath.Join(utils.MigrationsDir, "20242409125510_premium_mister_fear", "schema.sql")
require.NoError(t, afero.WriteFile(fsys, sqlPath, []byte("select 1"), 0644))
// Run test
result, err := GetMigrationFile("20242409125510", fsys)
assert.NoError(t, err)
assert.Equal(t, sqlPath, result)
})

t.Run("returns error for multiple .sql files in directory", func(t *testing.T) {
fsys := afero.NewMemMapFs()
dir := filepath.Join(utils.MigrationsDir, "20242409125510_premium_mister_fear")
require.NoError(t, afero.WriteFile(fsys, filepath.Join(dir, "schema.sql"), []byte("select 1"), 0644))
require.NoError(t, afero.WriteFile(fsys, filepath.Join(dir, "extra.sql"), []byte("select 2"), 0644))
// Run test
_, err := GetMigrationFile("20242409125510", fsys)
assert.ErrorContains(t, err, "multiple .sql files found")
})

t.Run("returns error when version not found", func(t *testing.T) {
fsys := afero.NewMemMapFs()
// Run test
_, err := GetMigrationFile("99999", fsys)
assert.ErrorIs(t, err, os.ErrNotExist)
})
}

func TestRepairAll(t *testing.T) {
t.Run("repairs whole history", func(t *testing.T) {
t.Cleanup(fstest.MockStdin(t, "y"))
Expand Down
9 changes: 9 additions & 0 deletions internal/migration/squash/squash.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"time"

Expand Down Expand Up @@ -73,6 +74,14 @@ func squashToVersion(ctx context.Context, version string, fsys afero.Fs, options
for _, path := range migrations[:len(migrations)-1] {
if err := fsys.Remove(path); err != nil {
fmt.Fprintln(os.Stderr, err)
continue
}
// For folder-based migrations, remove the parent directory and all its contents
dir := filepath.Dir(path)
if dir != utils.MigrationsDir {
if err := fsys.RemoveAll(dir); err != nil {
fmt.Fprintln(os.Stderr, err)
}
}
}
return nil
Expand Down
9 changes: 3 additions & 6 deletions pkg/migration/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"fmt"
"io/fs"
"os"
"path/filepath"

"github.com/go-errors/errors"
"github.com/jackc/pgx/v4"
Expand All @@ -23,9 +22,8 @@ func FindPendingMigrations(localMigrations, remoteMigrations []string) ([]string
i, j := 0, 0
for i < len(remoteMigrations) && j < len(localMigrations) {
remote := remoteMigrations[i]
filename := filepath.Base(localMigrations[j])
// Check if migration has been applied before, LoadLocalMigrations guarantees a match
local := migrateFilePattern.FindStringSubmatch(filename)[1]
// Extract version from path, supporting both flat files and folder-based migrations
local, _, _ := ParseVersion(localMigrations[j])
if remote == local {
j++
i++
Expand Down Expand Up @@ -60,8 +58,7 @@ func ApplyMigrations(ctx context.Context, pending []string, conn *pgx.Conn, fsys
}
}
for _, path := range pending {
filename := filepath.Base(path)
fmt.Fprintf(os.Stderr, "Applying migration %s...\n", filename)
fmt.Fprintf(os.Stderr, "Applying migration %s...\n", MigrationName(path))
// Reset all connection settings that might have been modified by another statement on the same connection
// eg: `SELECT pg_catalog.set_config('search_path', '', false);`
if _, err := conn.Exec(ctx, "RESET ALL"); err != nil {
Expand Down
49 changes: 49 additions & 0 deletions pkg/migration/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,55 @@ func TestPendingMigrations(t *testing.T) {
assert.ErrorIs(t, err, ErrMissingLocal)
assert.ElementsMatch(t, []string{remote[1], remote[3], remote[4]}, missing)
})

t.Run("finds pending folder-based migrations", func(t *testing.T) {
local := []string{
"20221201000000_test.sql",
"20221201000001_test.sql",
"20221201000002_create_users/schema.sql",
"20221201000003_add_indexes/schema.sql",
}
remote := []string{
"20221201000000",
"20221201000001",
}
// Run test
pending, err := FindPendingMigrations(local, remote)
// Check error
assert.NoError(t, err)
assert.ElementsMatch(t, local[2:], pending)
})

t.Run("matches remote with local folder-based migration", func(t *testing.T) {
local := []string{
"20221201000000_test.sql",
"20221201000001_create_users/schema.sql",
}
remote := []string{
"20221201000000",
"20221201000001",
}
// Run test
pending, err := FindPendingMigrations(local, remote)
// Check error
assert.NoError(t, err)
assert.Empty(t, pending)
})

t.Run("detects missing local for folder-based migrations", func(t *testing.T) {
local := []string{
"20221201000000_create_users/schema.sql",
}
remote := []string{
"20221201000000",
"20221201000001",
}
// Run test
missing, err := FindPendingMigrations(local, remote)
// Check error
assert.ErrorIs(t, err, ErrMissingLocal)
assert.ElementsMatch(t, []string{"20221201000001"}, missing)
})
}

var (
Expand Down
40 changes: 34 additions & 6 deletions pkg/migration/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type MigrationFile struct {

var (
migrateFilePattern = regexp.MustCompile(`^([0-9]+)_(.*)\.sql$`)
migrateDirPattern = regexp.MustCompile(`^([0-9]+)_(.+)$`)
typeNamePattern = regexp.MustCompile(`type "([^"]+)" does not exist`)
)

Expand All @@ -37,16 +38,43 @@ func NewMigrationFromFile(path string, fsys fs.FS) (*MigrationFile, error) {
return nil, err
}
file := MigrationFile{Statements: lines}
// Parse version from file name
filename := filepath.Base(path)
matches := migrateFilePattern.FindStringSubmatch(filename)
if len(matches) > 2 {
file.Version = matches[1]
file.Name = matches[2]
// Parse version from file path (supports both flat files and folder-based migrations)
if version, name, ok := ParseVersion(path); ok {
file.Version = version
file.Name = name
}
return &file, nil
}

// ParseVersion extracts the version and name from a migration path.
// Handles both flat files (20220727064247_create_table.sql) and
// folder-based migrations (20242409125510_premium_mister_fear/<file>.sql).
func ParseVersion(path string) (version, name string, ok bool) {
filename := filepath.Base(path)
if matches := migrateFilePattern.FindStringSubmatch(filename); len(matches) > 2 {
return matches[1], matches[2], true
}
// Try parent directory for folder-based migrations
dirName := filepath.Base(filepath.Dir(path))
if matches := migrateDirPattern.FindStringSubmatch(dirName); len(matches) > 2 {
return matches[1], matches[2], true
}
return "", "", false
}

// MigrationName returns a human-readable display name for a migration path.
// For flat files: "20220727064247_create_table.sql"
// For folder migrations: "20242409125510_premium_mister_fear/<file>.sql"
func MigrationName(path string) string {
filename := filepath.Base(path)
if migrateFilePattern.MatchString(filename) {
return filename
}
// For folder-based migrations, show "dirname/filename"
dir := filepath.Base(filepath.Dir(path))
return filepath.Join(dir, filename)
}

func parseFile(path string, fsys fs.FS) ([]string, error) {
sql, err := fsys.Open(path)
if err != nil {
Expand Down
72 changes: 72 additions & 0 deletions pkg/migration/file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,21 @@ func TestMigrationFile(t *testing.T) {
assert.ErrorContains(t, err, "At statement: 0")
})

t.Run("new from folder-based migration", func(t *testing.T) {
// Setup in-memory fs
fsys := fs.MapFS{
"20242409125510_premium_mister_fear/schema.sql": &fs.MapFile{Data: []byte("CREATE TABLE foo (id int)")},
"20242409125510_premium_mister_fear/snapshot.json": &fs.MapFile{Data: []byte("{}")},
}
// Run test
migration, err := NewMigrationFromFile("20242409125510_premium_mister_fear/schema.sql", fsys)
// Check error
assert.NoError(t, err)
assert.Equal(t, "20242409125510", migration.Version)
assert.Equal(t, "premium_mister_fear", migration.Name)
assert.Len(t, migration.Statements, 1)
})

t.Run("skips hint for schema-qualified type errors", func(t *testing.T) {
migration := MigrationFile{
Statements: []string{"CREATE TABLE test (path extensions.ltree NOT NULL)"},
Expand Down Expand Up @@ -158,3 +173,60 @@ func TestIsSchemaQualified(t *testing.T) {
assert.False(t, IsSchemaQualified("ltree"))
assert.False(t, IsSchemaQualified(""))
}

func TestParseVersion(t *testing.T) {
t.Run("extracts version from flat file", func(t *testing.T) {
version, name, ok := ParseVersion("20220727064247_create_table.sql")
assert.True(t, ok)
assert.Equal(t, "20220727064247", version)
assert.Equal(t, "create_table", name)
})

t.Run("extracts version from flat file with path", func(t *testing.T) {
version, name, ok := ParseVersion("supabase/migrations/20220727064247_create_table.sql")
assert.True(t, ok)
assert.Equal(t, "20220727064247", version)
assert.Equal(t, "create_table", name)
})

t.Run("extracts version from folder-based migration", func(t *testing.T) {
version, name, ok := ParseVersion("supabase/migrations/20242409125510_premium_mister_fear/schema.sql")
assert.True(t, ok)
assert.Equal(t, "20242409125510", version)
assert.Equal(t, "premium_mister_fear", name)
})

t.Run("extracts version from folder-based migration without parent path", func(t *testing.T) {
version, name, ok := ParseVersion("20242409125510_premium_mister_fear/schema.sql")
assert.True(t, ok)
assert.Equal(t, "20242409125510", version)
assert.Equal(t, "premium_mister_fear", name)
})

t.Run("returns false for non-matching path", func(t *testing.T) {
_, _, ok := ParseVersion("random_file.txt")
assert.False(t, ok)
})

t.Run("returns false for .sql without matching parent dir", func(t *testing.T) {
_, _, ok := ParseVersion("some_dir/schema.sql")
assert.False(t, ok)
})
}

func TestMigrationName(t *testing.T) {
t.Run("returns filename for flat migration", func(t *testing.T) {
assert.Equal(t, "20220727064247_create_table.sql", MigrationName("supabase/migrations/20220727064247_create_table.sql"))
})

t.Run("returns dir/file for folder-based migration", func(t *testing.T) {
assert.Equal(t,
"20242409125510_premium_mister_fear/schema.sql",
MigrationName("supabase/migrations/20242409125510_premium_mister_fear/schema.sql"),
)
})

t.Run("returns filename when no parent directory", func(t *testing.T) {
assert.Equal(t, "20220727064247_create_table.sql", MigrationName("20220727064247_create_table.sql"))
})
}
Loading
Loading