package db import ( "errors" "fmt" "io/fs" "log/slog" "github.com/golang-migrate/migrate/v4" migratepgx "github.com/golang-migrate/migrate/v4/database/pgx/v5" "github.com/golang-migrate/migrate/v4/source/iofs" "visiona-backend/internal/config" "visiona-backend/migrations" ) // migrationsPath 是 iofs source 內的子路徑。embed FS 以 migrations 套件目錄為根, // SQL 檔直接在根(embed.go 的 `//go:embed *.sql`),故路徑為 "."。 const migrationsPath = "." // Migrator 包裝 golang-migrate,提供 visionA-backend 統一的 migration 進出點。 // // 使用嵌入式 migration(migrations.FS)+ pgx/v5 database driver。 // 每個 Migrator 持有自己的 *migrate.Migrate,用完應 Close()。 type Migrator struct { m *migrate.Migrate log *slog.Logger } // NewMigrator 從 DatabaseConfig 建立 Migrator(連到 cfg 指定的 DB)。 // // 注意:golang-migrate 自己開一條連線(與 pgxpool 分離),完成後 Close() 釋放。 // 這是 golang-migrate 的設計(migration 需要獨佔的 advisory lock 連線)。 func NewMigrator(cfg config.DatabaseConfig, log *slog.Logger) (*Migrator, error) { if log == nil { log = slog.Default() } src, err := iofs.New(migrations.FS, migrationsPath) if err != nil { return nil, fmt.Errorf("db: open embedded migrations source: %w", err) } m, err := migrate.NewWithSourceInstance("iofs", src, "pgx5://"+migrateDSN(cfg)) if err != nil { return nil, fmt.Errorf("db: init migrator (target=%s): %w", SafeTarget(cfg), err) } return &Migrator{m: m, log: log}, nil } // newMigratorFromDSN 直接以完整 DSN 建立 Migrator,供整合測試(testcontainers 給 raw DSN)使用。 func newMigratorFromDSN(rawDSN string, log *slog.Logger) (*Migrator, error) { if log == nil { log = slog.Default() } src, err := iofs.New(migrations.FS, migrationsPath) if err != nil { return nil, fmt.Errorf("db: open embedded migrations source: %w", err) } m, err := migrate.NewWithSourceInstance("iofs", src, rawDSN) if err != nil { return nil, fmt.Errorf("db: init migrator from dsn: %w", err) } return &Migrator{m: m, log: log}, nil } // Up 套用所有尚未執行的 migration。 // // 冪等:已是最新版(migrate.ErrNoChange)視為成功、不回 error。 func (mg *Migrator) Up() error { err := mg.m.Up() if err != nil && !errors.Is(err, migrate.ErrNoChange) { return fmt.Errorf("db: migrate up: %w", err) } if errors.Is(err, migrate.ErrNoChange) { mg.log.Info("migrate up: no change (already at latest version)") } else { ver, _, _ := mg.m.Version() mg.log.Info("migrate up: applied", "version", ver) } return nil } // Down 回退一個版本(給整合測試 / cmd/migrate 用,正常啟動流程不呼叫)。 func (mg *Migrator) Down() error { err := mg.m.Steps(-1) if err != nil && !errors.Is(err, migrate.ErrNoChange) && !errors.Is(err, fs.ErrNotExist) { return fmt.Errorf("db: migrate down one step: %w", err) } return nil } // Version 回傳目前 schema 版本與是否處於 dirty 狀態。 // 尚無任何 migration 時回 (0, false, migrate.ErrNilVersion)。 func (mg *Migrator) Version() (uint, bool, error) { return mg.m.Version() } // Close 釋放 migrator 的 source 與 database 連線。 func (mg *Migrator) Close() error { srcErr, dbErr := mg.m.Close() if srcErr != nil { return srcErr } return dbErr } // RunMigrations 是「建好池後自動 migrate up」的便利函式,供 main.go 在啟動流程呼叫。 // // 它自建一個 Migrator、跑 Up、再 Close,呼叫端不需管 Migrator 生命週期。 func RunMigrations(cfg config.DatabaseConfig, log *slog.Logger) error { mg, err := NewMigrator(cfg, log) if err != nil { return err } defer func() { if cerr := mg.Close(); cerr != nil { log.Warn("migrator close error", "error", cerr) } }() return mg.Up() } // migrateDSN 組 golang-migrate pgx5 driver 用的 DSN(不含 pgx5:// scheme prefix, // 由呼叫端拼上)。 // // 關鍵:golang-migrate 的 pgx5 driver 走標準 database/sql 單連線,PG server 端**不認得** // pgxpool 專屬參數(pool_max_conns / pool_min_conns)會直接 FATAL // (unrecognized configuration parameter, SQLSTATE 42704)。因此這裡走 baseDSN(只含 // 標準 libpq 參數 host/port/user/password/dbname/sslmode),**不**走 BuildDSN。 // // golang-migrate pgx5 driver 接受 "pgx5://user:pass@host:port/db?sslmode=..." 形式, // 故把 baseDSN 的 "postgres://" scheme 去掉、由 NewMigrator 拼回 "pgx5://"。 func migrateDSN(cfg config.DatabaseConfig) string { dsn := baseDSN(cfg) // baseDSN 回 "postgres://...", 去掉 "postgres://" 留下 "user:pass@host/db?..."。 const prefix = "postgres://" if len(dsn) >= len(prefix) && dsn[:len(prefix)] == prefix { return dsn[len(prefix):] } return dsn } // 確保 driver 被 link 進 binary(golang-migrate 用 blank import 註冊 driver; // 這裡顯式 import + 引用避免被當 unused)。 var _ = migratepgx.ErrNilConfig