package db_utils import ( "database/sql" "errors" "fmt" "github.com/golang-migrate/migrate/v4" pgx "github.com/golang-migrate/migrate/v4/database/pgx/v5" "github.com/golang-migrate/migrate/v4/database/sqlite3" _ "github.com/golang-migrate/migrate/v4/source/file" ) var ( OnNewInstance = errors.New("creating new instance") OnVersionCheck = errors.New("checking DB version") OnDrop = errors.New("dropping DB") ) type MigrationConfig[InstanceCfgT any] struct { // File path // format: path or /absolutepath MigrationsPath string // format: sqlite3://path/to/db // postgresql://user:password@ip:port/dbname?conn_opts DB *sql.DB // Put -1 for no limit, 0 to down database // and any number > 0 to limit version VersionLimit int // Drop DB before applying migrations Drop bool // Config used for migration connects DriverCfg InstanceCfgT } // NewMigrateSQLiteInstance is a newInstance function for sqlite3 database driver func NewMigrateSQLiteInstance(db *sql.DB, sourceURL string, cfg *sqlite3.Config) (*migrate.Migrate, error) { driver, err := sqlite3.WithInstance(db, cfg) if err != nil { return nil, err } return migrate.NewWithDatabaseInstance(sourceURL, "sqlite3", driver) } // NewMigratePgxInstance is a newInstance function for pgx/v5 database driver func NewMigratePgxInstance(db *sql.DB, sourceURL string, cfg *pgx.Config) (*migrate.Migrate, error) { driver, err := pgx.WithInstance(db, cfg) if err != nil { return nil, err } return migrate.NewWithDatabaseInstance(sourceURL, "pgx/v5", driver) } // DoMigrate applies migration to a database func DoMigrate[InstanceCfgT any](cfg MigrationConfig[InstanceCfgT], newInstance func(db *sql.DB, sourceURL string, cfg InstanceCfgT) (*migrate.Migrate, error)) (uint, bool, error) { var ver uint var dirty bool var sourceURL string = fmt.Sprintf("file://%s", cfg.MigrationsPath) m, err := newInstance(cfg.DB, sourceURL, cfg.DriverCfg) if err != nil { return 0, false, errors.Join(OnNewInstance, err) } // Drop db if needed, get current db if cfg.Drop { if err = m.Drop(); err != nil { return 0, false, errors.Join(OnDrop, err) } // After drop, we have to create new migrate instance m, err = newInstance(cfg.DB, sourceURL, cfg.DriverCfg) if err != nil { return 0, false, errors.Join(OnNewInstance, err) } } else { // It's strange to check DB version after we drop it // So I put version checking into else statement ver, dirty, err = m.Version() if err != nil && !errors.Is(err, migrate.ErrNilVersion) { return 0, false, errors.Join(OnVersionCheck, err) } if dirty { if err = m.Drop(); err != nil { return ver, dirty, errors.Join(OnDrop, err) } // After drop, we have to create new migrate instance m, err = newInstance(cfg.DB, sourceURL, cfg.DriverCfg) if err != nil { return ver, dirty, errors.Join(OnNewInstance, err) } // As we dropped DB ver = 0 dirty = false } } if cfg.VersionLimit == int(ver) { return ver, dirty, nil } migratingUp := true if cfg.VersionLimit > 0 { if cfg.VersionLimit < int(ver) { migratingUp = false } err = m.Migrate(uint(cfg.VersionLimit)) } else if cfg.VersionLimit == 0 { migratingUp = false err = m.Down() } else { err = m.Up() } if err != nil { if !errors.Is(err, migrate.ErrNoChange) { return 0, false, errors.Join(OnNewInstance, err, fmt.Errorf("version limit: %d; migrating up: %v", cfg.VersionLimit, migratingUp)) } return ver, dirty, nil } ver, dirty, err = m.Version() if err != nil { return ver, dirty, errors.Join(OnVersionCheck, err) } return ver, dirty, nil }