forgejo/tests/integration/migration-test/migration_test.go
pat-s c39a4368af refactor: migrate from lib/pq to jackc/pgx (#10219)
This PR migrates the unmaintaiend `lib/pq` library to `jackc/pgx`, which is the de-facto standard lib in go for postgres connections these days.

Some implementation notes:

We register both `pgx` and `postgresschema` driver names (for backward comp). We can't register `postgres` as this one is still used by `lib/pq` imported by `go-chi/session`, which is in use when users go for the "postgres" session type in the "Session config.
It is questionable if anyone is really using the "postgres" driver option in the session config - but for consistency, it would be good to also migrate to `pgx` there, especially as the code lives within Forgejo under [go-chi/session](https://code.forgejo.org/go-chi/session).

`pgx` supports multi-host notation in the connection string. New tests have been added therefore.

`pgx` also allows for connection string parameters such as `?default_query_exec_mode=simple_protocol`. This should possibly allow running with `pgbouncer` "transaction" mode instead of "session", which could substantially enhance Postgres query handling.

## Checklist

### Tests

- I added test coverage for Go changes...
  - [x] in their respective `*_test.go` for unit tests.
  - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I added test coverage for JavaScript changes...
  - [ ] in `web_src/js/*.test.js` if it can be unit tested.
  - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)).

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [x] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [ ] I do not want this change to show in the release notes.
- [x] I want the title to show in the release notes with a link to this pull request.
- [ ] I want the content of the `release-notes/<pull request number>.md` to be be used for the release notes instead of the title.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10219
Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
Co-authored-by: pat-s <patrick.schratz@gmail.com>
Co-committed-by: pat-s <patrick.schratz@gmail.com>
2025-11-30 17:47:45 +01:00

331 lines
10 KiB
Go

// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package migrations
import (
"compress/gzip"
"database/sql"
"fmt"
"io"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
"forgejo.org/models/db"
"forgejo.org/models/gitea_migrations"
migrate_base "forgejo.org/models/gitea_migrations/base"
"forgejo.org/models/unittest"
"forgejo.org/modules/base"
"forgejo.org/modules/charset"
"forgejo.org/modules/git"
"forgejo.org/modules/log"
"forgejo.org/modules/setting"
"forgejo.org/modules/testlogger"
"forgejo.org/modules/util"
"forgejo.org/tests"
_ "github.com/jackc/pgx/v5/stdlib" // Import pgx driver
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"xorm.io/xorm"
)
var currentEngine *xorm.Engine
func getRoot(t *testing.T) string {
t.Helper()
root := base.SetupGiteaRoot()
if root == "" {
t.Fatal("Environment variable $GITEA_ROOT not set")
}
return root
}
func initMigrationTest(t *testing.T) func() {
log.RegisterEventWriter("test", testlogger.NewTestLoggerWriter)
deferFn := tests.PrintCurrentTest(t, 2)
root := getRoot(t)
setting.AppPath = path.Join(root, "gitea")
if _, err := os.Stat(setting.AppPath); err != nil {
tests.Printf("Could not find gitea binary at %s\n", setting.AppPath)
os.Exit(1)
}
giteaConf := os.Getenv("GITEA_CONF")
if giteaConf == "" {
tests.Printf("Environment variable $GITEA_CONF not set\n")
os.Exit(1)
} else if !path.IsAbs(giteaConf) {
setting.CustomConf = path.Join(root, giteaConf)
} else {
setting.CustomConf = giteaConf
}
unittest.InitSettings()
assert.NotEmpty(t, setting.RepoRootPath)
require.NoError(t, util.RemoveAll(setting.RepoRootPath))
require.NoError(t, unittest.CopyDir(path.Join(filepath.Dir(setting.AppPath), "tests/gitea-repositories-meta"), setting.RepoRootPath))
ownerDirs, err := os.ReadDir(setting.RepoRootPath)
if err != nil {
require.NoError(t, err, "unable to read the new repo root: %v\n", err)
}
for _, ownerDir := range ownerDirs {
if !ownerDir.Type().IsDir() {
continue
}
repoDirs, err := os.ReadDir(filepath.Join(setting.RepoRootPath, ownerDir.Name()))
if err != nil {
require.NoError(t, err, "unable to read the new repo root: %v\n", err)
}
for _, repoDir := range repoDirs {
_ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "objects", "pack"), 0o755)
_ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "objects", "info"), 0o755)
_ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "refs", "heads"), 0o755)
_ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "refs", "tag"), 0o755)
}
}
require.NoError(t, git.InitFull(t.Context()))
setting.LoadDBSetting()
setting.InitLoggersForTest()
return deferFn
}
func availableVersions(t *testing.T) []string {
t.Helper()
root := getRoot(t)
migrationsDir, err := os.Open(path.Join(root, "tests/integration/migration-test"))
require.NoError(t, err)
defer migrationsDir.Close()
versionRE, err := regexp.Compile(".*-v(?P<version>.+)\\." + regexp.QuoteMeta(setting.Database.Type.String()) + "\\.sql.gz")
require.NoError(t, err)
filenames, err := migrationsDir.Readdirnames(-1)
require.NoError(t, err)
versions := []string{}
for _, filename := range filenames {
if versionRE.MatchString(filename) {
substrings := versionRE.FindStringSubmatch(filename)
versions = append(versions, substrings[1])
}
}
sort.Strings(versions)
return versions
}
func readSQLFromFile(t *testing.T, version string) string {
t.Helper()
root := getRoot(t)
filename := fmt.Sprintf(path.Join(root, "tests/integration/migration-test/gitea-v%s.%s.sql.gz"), version, setting.Database.Type)
if _, err := os.Stat(filename); os.IsNotExist(err) {
filename = fmt.Sprintf(path.Join(root, "tests/integration/migration-test/forgejo-v%s.%s.sql.gz"), version, setting.Database.Type)
if _, err := os.Stat(filename); os.IsNotExist(err) {
require.NoError(t, err)
}
}
file, err := os.Open(filename)
require.NoError(t, err)
defer file.Close()
gr, err := gzip.NewReader(file)
require.NoError(t, err)
defer gr.Close()
bytes, err := io.ReadAll(gr)
require.NoError(t, err)
return string(charset.MaybeRemoveBOM(bytes, charset.ConvertOpts{}))
}
func restoreOldDB(t *testing.T, version string) bool {
data := readSQLFromFile(t, version)
if len(data) == 0 {
tests.Printf("No db found to restore for %s version: %s\n", setting.Database.Type, version)
return false
}
switch {
case setting.Database.Type.IsSQLite3():
util.Remove(setting.Database.Path)
err := os.MkdirAll(path.Dir(setting.Database.Path), os.ModePerm)
require.NoError(t, err)
db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?cache=shared&mode=rwc&_busy_timeout=%d&_txlock=immediate", setting.Database.Path, setting.Database.Timeout))
require.NoError(t, err)
defer db.Close()
_, err = db.Exec(data)
require.NoError(t, err)
db.Close()
case setting.Database.Type.IsMySQL():
db, err := sql.Open("mysql", fmt.Sprintf("%s:%s@tcp(%s)/",
setting.Database.User, setting.Database.Passwd, setting.Database.Host))
require.NoError(t, err)
defer db.Close()
databaseName := strings.SplitN(setting.Database.Name, "?", 2)[0]
_, err = db.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", databaseName))
require.NoError(t, err)
_, err = db.Exec(fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", databaseName))
require.NoError(t, err)
db.Close()
db, err = sql.Open("mysql", fmt.Sprintf("%s:%s@tcp(%s)/%s",
setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.Name))
require.NoError(t, err)
defer db.Close()
_, err = db.Exec(data)
require.NoError(t, err)
db.Close()
case setting.Database.Type.IsPostgreSQL():
var db *sql.DB
var err error
if setting.Database.Host[0] == '/' {
db, err = sql.Open("pgx", fmt.Sprintf("postgres://%s:%s@/?sslmode=%s&host=%s",
setting.Database.User, setting.Database.Passwd, setting.Database.SSLMode, setting.Database.Host))
require.NoError(t, err)
} else {
db, err = sql.Open("pgx", fmt.Sprintf("postgres://%s:%s@%s/?sslmode=%s",
setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.SSLMode))
require.NoError(t, err)
}
defer db.Close()
_, err = db.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", setting.Database.Name))
require.NoError(t, err)
_, err = db.Exec(fmt.Sprintf("CREATE DATABASE %s", setting.Database.Name))
require.NoError(t, err)
db.Close()
// Check if we need to setup a specific schema
if len(setting.Database.Schema) != 0 {
if setting.Database.Host[0] == '/' {
db, err = sql.Open("pgx", fmt.Sprintf("postgres://%s:%s@/%s?sslmode=%s&host=%s",
setting.Database.User, setting.Database.Passwd, setting.Database.Name, setting.Database.SSLMode, setting.Database.Host))
} else {
db, err = sql.Open("pgx", fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s",
setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.Name, setting.Database.SSLMode))
}
require.NoError(t, err)
defer db.Close()
schrows, err := db.Query(fmt.Sprintf("SELECT 1 FROM information_schema.schemata WHERE schema_name = '%s'", setting.Database.Schema))
require.NoError(t, err)
if !assert.NotEmpty(t, schrows) {
return false
}
if !schrows.Next() {
// Create and setup a DB schema
_, err = db.Exec(fmt.Sprintf("CREATE SCHEMA %s", setting.Database.Schema))
require.NoError(t, err)
}
schrows.Close()
// Make the user's default search path the created schema; this will affect new connections
_, err = db.Exec(fmt.Sprintf(`ALTER USER "%s" SET search_path = %s`, setting.Database.User, setting.Database.Schema))
require.NoError(t, err)
db.Close()
}
if setting.Database.Host[0] == '/' {
db, err = sql.Open("pgx", fmt.Sprintf("postgres://%s:%s@/%s?sslmode=%s&host=%s",
setting.Database.User, setting.Database.Passwd, setting.Database.Name, setting.Database.SSLMode, setting.Database.Host))
} else {
db, err = sql.Open("pgx", fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s",
setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.Name, setting.Database.SSLMode))
}
require.NoError(t, err)
defer db.Close()
_, err = db.Exec(data)
require.NoError(t, err)
db.Close()
}
return true
}
func wrappedMigrate(x *xorm.Engine) error {
currentEngine = x
return gitea_migrations.Migrate(x)
}
func doMigrationTest(t *testing.T, version string) {
defer tests.PrintCurrentTest(t)()
tests.Printf("Performing migration test for %s version: %s\n", setting.Database.Type, version)
if !restoreOldDB(t, version) {
return
}
setting.InitSQLLoggersForCli(log.INFO)
err := db.InitEngineWithMigration(t.Context(), func(e db.Engine) error {
engine, err := db.GetMasterEngine(e)
if err != nil {
return err
}
currentEngine = engine
return wrappedMigrate(engine)
})
require.NoError(t, err)
currentEngine.Close()
beans, _ := db.NamesToBean()
err = db.InitEngineWithMigration(t.Context(), func(e db.Engine) error {
currentEngine, err = db.GetMasterEngine(e)
if err != nil {
return err
}
return migrate_base.RecreateTables(beans...)(currentEngine)
})
require.NoError(t, err)
currentEngine.Close()
// We do this a second time to ensure that there is not a problem with retained indices
err = db.InitEngineWithMigration(t.Context(), func(e db.Engine) error {
currentEngine, err = db.GetMasterEngine(e)
if err != nil {
return err
}
return migrate_base.RecreateTables(beans...)(currentEngine)
})
require.NoError(t, err)
currentEngine.Close()
}
func TestMigrations(t *testing.T) {
defer initMigrationTest(t)()
dialect := setting.Database.Type
versions := availableVersions(t)
if len(versions) == 0 {
tests.Printf("No old database versions available to migration test for %s\n", dialect)
return
}
tests.Printf("Preparing to test %d migrations for %s\n", len(versions), dialect)
for _, version := range versions {
t.Run(fmt.Sprintf("Migrate-%s-%s", dialect, version), func(t *testing.T) {
doMigrationTest(t, version)
})
}
}