Compare commits
1 Commits
3d92f2625b
...
5504495e0c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5504495e0c |
@@ -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...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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() })
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user