1 Commits

Author SHA1 Message Date
user
5504495e0c Move schema_migrations table creation from Go code into 000.sql
All checks were successful
check / check (push) Successful in 1m0s
The schema_migrations table definition now lives in
internal/database/schema/000.sql instead of being hardcoded as an
inline SQL string in database.go. A bootstrap step checks
sqlite_master for the table and applies 000.sql when it is missing.
Existing databases that already have the table (created by older
inline code) get version 000 back-filled so the normal migration
loop skips the file.

Also deduplicates the migration logic: both the Database.runMigrations
method and the exported ApplyMigrations helper now delegate to a single
applyMigrations function.

Adds database_test.go with tests for fresh migration, idempotency,
bootstrap on a fresh DB, and backwards compatibility with legacy DBs.
2026-03-17 01:56:57 -07:00
6 changed files with 103 additions and 240 deletions

View File

@@ -39,41 +39,6 @@ type Database struct {
config *config.Config config *config.Config
} }
// ParseMigrationVersion extracts the numeric version prefix from a migration
// filename. Filenames must follow the pattern "<version>.sql" or
// "<version>_<description>.sql", where version is a zero-padded numeric
// string (e.g. "001", "002"). Returns the version string and an error if
// the filename does not match the expected pattern.
func ParseMigrationVersion(filename string) (string, error) {
name := strings.TrimSuffix(filename, filepath.Ext(filename))
if name == "" {
return "", fmt.Errorf("invalid migration filename %q: empty name", filename)
}
// Split on underscore to separate version from description.
// If there's no underscore, the entire stem is the version.
version := name
if idx := strings.IndexByte(name, '_'); idx >= 0 {
version = name[:idx]
}
if version == "" {
return "", fmt.Errorf("invalid migration filename %q: empty version prefix", filename)
}
// Validate the version is purely numeric.
for _, ch := range version {
if ch < '0' || ch > '9' {
return "", fmt.Errorf(
"invalid migration filename %q: version %q contains non-numeric character %q",
filename, version, string(ch),
)
}
}
return version, nil
}
// New creates a new Database instance. // New creates a new Database instance.
func New(lc fx.Lifecycle, params Params) (*Database, error) { func New(lc fx.Lifecycle, params Params) (*Database, error) {
s := &Database{ s := &Database{
@@ -123,19 +88,22 @@ func (s *Database) connect(ctx context.Context) error {
s.db = db s.db = db
s.log.Info("database connected") s.log.Info("database connected")
return ApplyMigrations(ctx, s.db, s.log) return applyMigrations(ctx, s.db, s.log)
} }
// collectMigrations reads the embedded schema directory and returns // applyMigrations bootstraps the migrations table from 000.sql and then
// migration filenames sorted lexicographically. // applies every remaining migration that has not been recorded yet.
func collectMigrations() ([]string, error) { func applyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
if err := bootstrapMigrationsTable(ctx, db, log); err != nil {
return err
}
entries, err := schemaFS.ReadDir("schema") entries, err := schemaFS.ReadDir("schema")
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read schema directory: %w", err) return fmt.Errorf("failed to read schema directory: %w", err)
} }
var migrations []string var migrations []string
for _, entry := range entries { for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") { if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
migrations = append(migrations, entry.Name()) migrations = append(migrations, entry.Name())
@@ -144,7 +112,49 @@ func collectMigrations() ([]string, error) {
sort.Strings(migrations) sort.Strings(migrations)
return migrations, nil for _, migration := range migrations {
version := strings.TrimSuffix(migration, filepath.Ext(migration))
var count int
err := db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
version,
).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check migration status: %w", err)
}
if count > 0 {
logDebug(log, "migration already applied", "version", version)
continue
}
content, err := schemaFS.ReadFile(filepath.Join("schema", migration))
if err != nil {
return fmt.Errorf("failed to read migration %s: %w", migration, err)
}
logInfo(log, "applying migration", "version", version)
_, err = db.ExecContext(ctx, string(content))
if err != nil {
return fmt.Errorf("failed to apply migration %s: %w", migration, err)
}
_, err = db.ExecContext(ctx,
"INSERT INTO schema_migrations (version) VALUES (?)",
version,
)
if err != nil {
return fmt.Errorf("failed to record migration %s: %w", migration, err)
}
logInfo(log, "migration applied successfully", "version", version)
}
return nil
} }
// bootstrapMigrationsTable ensures the schema_migrations table exists // bootstrapMigrationsTable ensures the schema_migrations table exists
@@ -162,15 +172,9 @@ func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger)
} }
if tableExists > 0 { if tableExists > 0 {
return ensureBootstrapVersionRecorded(ctx, db, log) // Table already exists (from older inline-SQL code or a
} // previous run). Make sure version "000" is recorded so the
// normal loop skips the bootstrap file.
return applyBootstrapMigration(ctx, db, log)
}
// ensureBootstrapVersionRecorded checks whether version "000" is already
// recorded in an existing schema_migrations table and inserts it if not.
func ensureBootstrapVersionRecorded(ctx context.Context, db *sql.DB, log *slog.Logger) error {
var recorded int var recorded int
err := db.QueryRowContext(ctx, err := db.QueryRowContext(ctx,
@@ -181,10 +185,7 @@ func ensureBootstrapVersionRecorded(ctx context.Context, db *sql.DB, log *slog.L
return fmt.Errorf("failed to check bootstrap migration status: %w", err) return fmt.Errorf("failed to check bootstrap migration status: %w", err)
} }
if recorded > 0 { if recorded == 0 {
return nil
}
_, err = db.ExecContext(ctx, _, err = db.ExecContext(ctx,
"INSERT INTO schema_migrations (version) VALUES (?)", "INSERT INTO schema_migrations (version) VALUES (?)",
bootstrapVersion, bootstrapVersion,
@@ -193,24 +194,19 @@ func ensureBootstrapVersionRecorded(ctx context.Context, db *sql.DB, log *slog.L
return fmt.Errorf("failed to record bootstrap migration: %w", err) return fmt.Errorf("failed to record bootstrap migration: %w", err)
} }
if log != nil { logInfo(log, "recorded bootstrap migration for existing table", "version", bootstrapVersion)
log.Info("recorded bootstrap migration for existing table", "version", bootstrapVersion)
} }
return nil return nil
} }
// applyBootstrapMigration reads and executes 000.sql to create the // Table does not exist — apply 000.sql to create it.
// schema_migrations table on a fresh database.
func applyBootstrapMigration(ctx context.Context, db *sql.DB, log *slog.Logger) error {
content, err := schemaFS.ReadFile("schema/000.sql") content, err := schemaFS.ReadFile("schema/000.sql")
if err != nil { if err != nil {
return fmt.Errorf("failed to read bootstrap migration 000.sql: %w", err) return fmt.Errorf("failed to read bootstrap migration 000.sql: %w", err)
} }
if log != nil { logInfo(log, "applying bootstrap migration", "version", bootstrapVersion)
log.Info("applying bootstrap migration", "version", bootstrapVersion)
}
_, err = db.ExecContext(ctx, string(content)) _, err = db.ExecContext(ctx, string(content))
if err != nil { if err != nil {
@@ -225,80 +221,7 @@ func applyBootstrapMigration(ctx context.Context, db *sql.DB, log *slog.Logger)
return fmt.Errorf("failed to record bootstrap migration: %w", err) return fmt.Errorf("failed to record bootstrap migration: %w", err)
} }
if log != nil { logInfo(log, "bootstrap migration applied successfully", "version", bootstrapVersion)
log.Info("bootstrap migration applied successfully", "version", bootstrapVersion)
}
return nil
}
// ApplyMigrations applies all pending migrations to db. An optional logger
// may be provided for informational output; pass nil for silent operation.
// This is exported so tests can apply the real schema without the full fx
// lifecycle.
func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
if err := bootstrapMigrationsTable(ctx, db, log); err != nil {
return err
}
migrations, err := collectMigrations()
if err != nil {
return err
}
for _, migration := range migrations {
version, parseErr := ParseMigrationVersion(migration)
if parseErr != nil {
return parseErr
}
// Check if already applied.
var count int
err := db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
version,
).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check migration status: %w", err)
}
if count > 0 {
if log != nil {
log.Debug("migration already applied", "version", version)
}
continue
}
// Read and apply migration.
content, readErr := schemaFS.ReadFile(filepath.Join("schema", migration))
if readErr != nil {
return fmt.Errorf("failed to read migration %s: %w", migration, readErr)
}
if log != nil {
log.Info("applying migration", "version", version)
}
_, execErr := db.ExecContext(ctx, string(content))
if execErr != nil {
return fmt.Errorf("failed to apply migration %s: %w", migration, execErr)
}
// Record migration as applied.
_, recErr := db.ExecContext(ctx,
"INSERT INTO schema_migrations (version) VALUES (?)",
version,
)
if recErr != nil {
return fmt.Errorf("failed to record migration %s: %w", migration, recErr)
}
if log != nil {
log.Info("migration applied successfully", "version", version)
}
}
return nil return nil
} }
@@ -307,3 +230,24 @@ func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
func (s *Database) DB() *sql.DB { func (s *Database) DB() *sql.DB {
return s.db return s.db
} }
// ApplyMigrations applies all migrations to the given database.
// This is useful for testing where you want to use the real schema
// without the full fx lifecycle.
func ApplyMigrations(db *sql.DB) error {
return applyMigrations(context.Background(), db, nil)
}
// logInfo logs at info level when a logger is available.
func logInfo(log *slog.Logger, msg string, args ...any) {
if log != nil {
log.Info(msg, args...)
}
}
// logDebug logs at debug level when a logger is available.
func logDebug(log *slog.Logger, msg string, args ...any) {
if log != nil {
log.Debug(msg, args...)
}
}

View File

@@ -22,89 +22,10 @@ func openTestDB(t *testing.T) *sql.DB {
return db return db
} }
func TestParseMigrationVersion(t *testing.T) {
tests := []struct {
name string
filename string
want string
wantErr bool
}{
{
name: "version only",
filename: "001.sql",
want: "001",
},
{
name: "version with description",
filename: "001_initial_schema.sql",
want: "001",
},
{
name: "multi-digit version",
filename: "042_add_indexes.sql",
want: "042",
},
{
name: "long version number",
filename: "00001_long_prefix.sql",
want: "00001",
},
{
name: "description with multiple underscores",
filename: "003_add_user_auth_tables.sql",
want: "003",
},
{
name: "empty filename",
filename: ".sql",
wantErr: true,
},
{
name: "leading underscore",
filename: "_description.sql",
wantErr: true,
},
{
name: "non-numeric version",
filename: "abc_migration.sql",
wantErr: true,
},
{
name: "mixed alphanumeric version",
filename: "001a_migration.sql",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseMigrationVersion(tt.filename)
if tt.wantErr {
if err == nil {
t.Errorf("ParseMigrationVersion(%q) expected error, got %q", tt.filename, got)
}
return
}
if err != nil {
t.Errorf("ParseMigrationVersion(%q) unexpected error: %v", tt.filename, err)
return
}
if got != tt.want {
t.Errorf("ParseMigrationVersion(%q) = %q, want %q", tt.filename, got, tt.want)
}
})
}
}
func TestApplyMigrations_CreatesSchemaAndTables(t *testing.T) { func TestApplyMigrations_CreatesSchemaAndTables(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
ctx := context.Background()
if err := ApplyMigrations(ctx, db, nil); err != nil { if err := ApplyMigrations(db); err != nil {
t.Fatalf("ApplyMigrations failed: %v", err) t.Fatalf("ApplyMigrations failed: %v", err)
} }
@@ -162,14 +83,13 @@ func TestApplyMigrations_CreatesSchemaAndTables(t *testing.T) {
func TestApplyMigrations_Idempotent(t *testing.T) { func TestApplyMigrations_Idempotent(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
ctx := context.Background()
if err := ApplyMigrations(ctx, db, nil); err != nil { if err := ApplyMigrations(db); err != nil {
t.Fatalf("first ApplyMigrations failed: %v", err) t.Fatalf("first ApplyMigrations failed: %v", err)
} }
// Running a second time must succeed without errors. // Running a second time must succeed without errors.
if err := ApplyMigrations(ctx, db, nil); err != nil { if err := ApplyMigrations(db); err != nil {
t.Fatalf("second ApplyMigrations failed: %v", err) t.Fatalf("second ApplyMigrations failed: %v", err)
} }

View File

@@ -82,7 +82,7 @@ func setupTestDB(t *testing.T) *sql.DB {
t.Fatalf("failed to open test db: %v", err) t.Fatalf("failed to open test db: %v", err)
} }
if err := database.ApplyMigrations(context.Background(), db, nil); err != nil { if err := database.ApplyMigrations(db); err != nil {
t.Fatalf("failed to apply migrations: %v", err) t.Fatalf("failed to apply migrations: %v", err)
} }

View File

@@ -16,7 +16,7 @@ func setupStatsTestDB(t *testing.T) *sql.DB {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := database.ApplyMigrations(context.Background(), db, nil); err != nil { if err := database.ApplyMigrations(db); err != nil {
t.Fatal(err) t.Fatal(err)
} }
t.Cleanup(func() { db.Close() }) t.Cleanup(func() { db.Close() })

View File

@@ -2,7 +2,6 @@ package imgcache
import ( import (
"bytes" "bytes"
"context"
"database/sql" "database/sql"
"image" "image"
"image/color" "image/color"
@@ -194,7 +193,7 @@ func setupServiceTestDB(t *testing.T) *sql.DB {
} }
// Use the real production schema via migrations // Use the real production schema via migrations
if err := database.ApplyMigrations(context.Background(), db, nil); err != nil { if err := database.ApplyMigrations(db); err != nil {
t.Fatalf("failed to apply migrations: %v", err) t.Fatalf("failed to apply migrations: %v", err)
} }