1
0
Fork 0
mirror of https://codeberg.org/forgejo/forgejo.git synced 2025-07-19 17:49:39 +02:00
forgejo/models/unittest/fixture_loader.go
Gusted 0ecb25fdcb chore: do not require empty fixtures to clean tables (#8353)
- A mistake that is often made is to not put a empty fixture for tables that gets populated in a test and should be cleaned up between runs. The CI does not detect such issues as these never arise on the first run of the test suite and are only noticed when developers run a test for a second time, unless you are aware of this behavior (which very few do as this is not documented and pure folk knowledge) can end up in chasing a bug that does not exist for hours. forgejo/forgejo#7041, forgejo/forgejo#7419, meissa/forgejo#21, forgejo/forgejo#5771
- Because we now own the fixture loader (forgejo/forgejo#7715), its rather simple to ensure that all tables that did not receive fixtures should be cleaned between runs and removes the need to place empty fixture files.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8353
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
Reviewed-by: Otto <otto@codeberg.org>
Co-authored-by: Gusted <postmaster@gusted.xyz>
Co-committed-by: Gusted <postmaster@gusted.xyz>
2025-06-30 23:04:16 +02:00

211 lines
4.6 KiB
Go

// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package unittest
import (
"database/sql"
"encoding/hex"
"encoding/json" //nolint:depguard
"fmt"
"os"
"path/filepath"
"strings"
"forgejo.org/modules/container"
"gopkg.in/yaml.v3"
)
type insertSQL struct {
statement string
values []any
}
type fixtureFile struct {
name string
insertSQLs []insertSQL
}
type loader struct {
db *sql.DB
dialect string
fixtureFiles []*fixtureFile
}
func newFixtureLoader(db *sql.DB, dialect string, fixturePaths []string, allTableNames container.Set[string]) (*loader, error) {
l := &loader{
db: db,
dialect: dialect,
fixtureFiles: []*fixtureFile{},
}
tablesWithoutFixture := allTableNames
// Load fixtures
for _, fixturePath := range fixturePaths {
stat, err := os.Stat(fixturePath)
if err != nil {
return nil, err
}
// If fixture path is a directory, then read read the files of the directory
// and use those as fixture files.
if stat.IsDir() {
files, err := os.ReadDir(fixturePath)
if err != nil {
return nil, err
}
for _, file := range files {
if !file.IsDir() {
fixtureFile, err := l.buildFixtureFile(filepath.Join(fixturePath, file.Name()))
if err != nil {
return nil, err
}
l.fixtureFiles = append(l.fixtureFiles, fixtureFile)
tablesWithoutFixture.Remove(fixtureFile.name)
}
}
} else {
fixtureFile, err := l.buildFixtureFile(fixturePath)
if err != nil {
return nil, err
}
l.fixtureFiles = append(l.fixtureFiles, fixtureFile)
}
}
// Even though these tables have no fixtures, they can still be used and ensure
// they are cleaned.
for table := range tablesWithoutFixture.Seq() {
l.fixtureFiles = append(l.fixtureFiles, &fixtureFile{
name: table,
})
}
return l, nil
}
// quoteKeyword returns the quoted string of keyword.
func (l *loader) quoteKeyword(keyword string) string {
switch l.dialect {
case "sqlite3":
return `"` + keyword + `"`
case "mysql":
return "`" + keyword + "`"
case "postgres":
parts := strings.Split(keyword, ".")
for i, p := range parts {
parts[i] = `"` + p + `"`
}
return strings.Join(parts, ".")
default:
return "invalid"
}
}
// placeholder returns the placeholder string.
func (l *loader) placeholder(index int) string {
if l.dialect == "postgres" {
return fmt.Sprintf("$%d", index)
}
return "?"
}
func (l *loader) buildFixtureFile(fixturePath string) (*fixtureFile, error) {
f, err := os.Open(fixturePath)
if err != nil {
return nil, err
}
defer f.Close()
var records []map[string]any
if err := yaml.NewDecoder(f).Decode(&records); err != nil {
return nil, err
}
fixture := &fixtureFile{
name: filepath.Base(strings.TrimSuffix(f.Name(), filepath.Ext(f.Name()))),
insertSQLs: []insertSQL{},
}
for _, record := range records {
columns := []string{}
sqlValues := []string{}
values := []any{}
i := 1
for key, value := range record {
columns = append(columns, l.quoteKeyword(key))
switch v := value.(type) {
case string:
// Try to decode hex.
if strings.HasPrefix(v, "0x") {
value, err = hex.DecodeString(strings.TrimPrefix(v, "0x"))
if err != nil {
return nil, err
}
}
case []any:
// Decode array.
var bytes []byte
bytes, err = json.Marshal(v)
if err != nil {
return nil, err
}
value = string(bytes)
}
values = append(values, value)
sqlValues = append(sqlValues, l.placeholder(i))
i++
}
// Construct the insert SQL.
fixture.insertSQLs = append(fixture.insertSQLs, insertSQL{
statement: fmt.Sprintf(
"INSERT INTO %s (%s) VALUES (%s)",
l.quoteKeyword(fixture.name),
strings.Join(columns, ", "),
strings.Join(sqlValues, ", "),
),
values: values,
})
}
return fixture, nil
}
func (l *loader) Load() error {
// Start transaction.
tx, err := l.db.Begin()
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
// Clean the table and re-insert the fixtures.
tableDeleted := make(container.Set[string])
for _, fixture := range l.fixtureFiles {
if !tableDeleted.Contains(fixture.name) {
if _, err := tx.Exec(fmt.Sprintf("DELETE FROM %s", l.quoteKeyword(fixture.name))); err != nil {
return fmt.Errorf("cannot delete table %s: %w", fixture.name, err)
}
tableDeleted.Add(fixture.name)
}
for _, insertSQL := range fixture.insertSQLs {
if _, err := tx.Exec(insertSQL.statement, insertSQL.values...); err != nil {
return fmt.Errorf("cannot insert %q with values %q: %w", insertSQL.statement, insertSQL.values, err)
}
}
}
return tx.Commit()
}