mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2025-10-19 02:43:18 +00:00

Discovered while testing #9708: Fresh install databases, which is also a process used by the integration tests, create tables by using `SyncAllTables`. The order that tables are created is ungoverned -- it occurs based upon the order that Go calls each module's `init` to register their schema models. With the current foreign keys in the database, this does not yet cause an error. But it will shortly. I've manually tested this fix in this way: - The correct order to create tables is that indicated by `foreignKeySortInsert`. This creates tables that are *referenced* by foreign keys before creating tables that do the referencing (eg. `user` before `tracked_time`). - If I modify this PR and sort the keys the opposite way `foreignKeySortDelete`, then integration tests fail: ``` ??? [TestLogger] 2025/10/15 22:37:58 models/db/engine.go:270:InitEngineWithMigration() [E] [Error SQL Query] CREATE TABLE IF NOT EXISTS "gtestschema"."tracked_time" ("id" BIGSERIAL PRIMARY KEY NOT NULL, "issue_id" BIGINT NULL, "user_id" BIGINT NULL, "created_unix" BIGINT NULL, "time" BIGINT NOT NULL, "deleted" BOOL DEFAULT false NOT NULL, CONSTRAINT "tracked_time_issue_id_fkey" FOREIGN KEY ("issue_id") REFERENCES "gtestschema"."issue" ("id"), CONSTRAINT "tracked_time_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "gtestschema"."user" ("id")); [] - pq: relation "gtestschema.issue" does not exist ??? [TestLogger] 2025/10/15 22:37:58 routers/common/db.go:36:InitDBEngine() [E] ORM engine initialization attempt #1/10 failed. ``` Therefore this PR which doesn't appear to fix anything today fixes a latent bug that will occur shortly (possibly in #9397, possibly when another foreign key is added, possibly if Go changes the order in which `init` functions are invoked). ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests - I added test coverage for Go changes... - [ ] 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 - [x] I do not want this change to show in the release notes. - [ ] 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/9709 Reviewed-by: Michael Kriese <michael.kriese@gmx.de> Co-authored-by: Mathieu Fenniak <mathieu@fenniak.net> Co-committed-by: Mathieu Fenniak <mathieu@fenniak.net>
463 lines
13 KiB
Go
Executable file
463 lines
13 KiB
Go
Executable file
// Copyright 2014 The Gogs Authors. All rights reserved.
|
|
// Copyright 2018 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package db
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"reflect"
|
|
"runtime/trace"
|
|
"strings"
|
|
"time"
|
|
|
|
"forgejo.org/modules/container"
|
|
"forgejo.org/modules/log"
|
|
"forgejo.org/modules/setting"
|
|
|
|
"xorm.io/xorm"
|
|
"xorm.io/xorm/contexts"
|
|
"xorm.io/xorm/names"
|
|
"xorm.io/xorm/schemas"
|
|
|
|
_ "github.com/go-sql-driver/mysql" // Needed for the MySQL driver
|
|
_ "github.com/lib/pq" // Needed for the Postgresql driver
|
|
)
|
|
|
|
var (
|
|
x *xorm.Engine
|
|
tables []any
|
|
initFuncs []func() error
|
|
)
|
|
|
|
// Engine represents a xorm engine or session.
|
|
type Engine interface {
|
|
Table(tableNameOrBean any) *xorm.Session
|
|
Count(...any) (int64, error)
|
|
Decr(column string, arg ...any) *xorm.Session
|
|
Delete(...any) (int64, error)
|
|
Truncate(...any) (int64, error)
|
|
Exec(...any) (sql.Result, error)
|
|
Find(any, ...any) error
|
|
Get(beans ...any) (bool, error)
|
|
ID(any) *xorm.Session
|
|
In(string, ...any) *xorm.Session
|
|
Incr(column string, arg ...any) *xorm.Session
|
|
Insert(...any) (int64, error)
|
|
Iterate(any, xorm.IterFunc) error
|
|
IsTableExist(any) (bool, error)
|
|
Join(joinOperator string, tablename, condition any, args ...any) *xorm.Session
|
|
SQL(any, ...any) *xorm.Session
|
|
Where(any, ...any) *xorm.Session
|
|
Asc(colNames ...string) *xorm.Session
|
|
Desc(colNames ...string) *xorm.Session
|
|
Limit(limit int, start ...int) *xorm.Session
|
|
NoAutoTime() *xorm.Session
|
|
SumInt(bean any, columnName string) (res int64, err error)
|
|
Sync(...any) error
|
|
Select(string) *xorm.Session
|
|
SetExpr(string, any) *xorm.Session
|
|
NotIn(string, ...any) *xorm.Session
|
|
OrderBy(any, ...any) *xorm.Session
|
|
Exist(...any) (bool, error)
|
|
Distinct(...string) *xorm.Session
|
|
Query(...any) ([]map[string][]byte, error)
|
|
Cols(...string) *xorm.Session
|
|
Context(ctx context.Context) *xorm.Session
|
|
Ping() error
|
|
}
|
|
|
|
// TableInfo returns table's information via an object
|
|
func TableInfo(v any) (*schemas.Table, error) {
|
|
return x.TableInfo(v)
|
|
}
|
|
|
|
// DumpTables dump tables information
|
|
func DumpTables(tables []*schemas.Table, w io.Writer, tp ...schemas.DBType) error {
|
|
return x.DumpTables(tables, w, tp...)
|
|
}
|
|
|
|
// RegisterModel registers model, if initfunc provided, it will be invoked after data model sync
|
|
func RegisterModel(bean any, initFunc ...func() error) {
|
|
tables = append(tables, bean)
|
|
if len(initFuncs) > 0 && initFunc[0] != nil {
|
|
initFuncs = append(initFuncs, initFunc[0])
|
|
}
|
|
}
|
|
|
|
func init() {
|
|
gonicNames := []string{"SSL", "UID"}
|
|
for _, name := range gonicNames {
|
|
names.LintGonicMapper[name] = true
|
|
}
|
|
}
|
|
|
|
// newXORMEngineGroup creates an xorm.EngineGroup (with one master and one or more slaves).
|
|
// It assumes you have separate master and slave DSNs defined via the settings package.
|
|
func newXORMEngineGroup() (Engine, error) {
|
|
// Retrieve master DSN from settings.
|
|
masterConnStr, err := setting.DBMasterConnStr()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to determine master DSN: %w", err)
|
|
}
|
|
|
|
var masterEngine *xorm.Engine
|
|
// For PostgreSQL: if a schema is provided, we use the special "postgresschema" driver.
|
|
if setting.Database.Type.IsPostgreSQL() && len(setting.Database.Schema) > 0 {
|
|
registerPostgresSchemaDriver()
|
|
masterEngine, err = xorm.NewEngine("postgresschema", masterConnStr)
|
|
} else {
|
|
masterEngine, err = xorm.NewEngine(setting.Database.Type.String(), masterConnStr)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create master engine: %w", err)
|
|
}
|
|
if setting.Database.Type.IsMySQL() {
|
|
masterEngine.Dialect().SetParams(map[string]string{"rowFormat": "DYNAMIC"})
|
|
}
|
|
masterEngine.SetSchema(setting.Database.Schema)
|
|
|
|
slaveConnStrs, err := setting.DBSlaveConnStrs()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load slave DSNs: %w", err)
|
|
}
|
|
|
|
var slaveEngines []*xorm.Engine
|
|
// Iterate over all slave DSNs and create engines
|
|
for _, dsn := range slaveConnStrs {
|
|
slaveEngine, err := xorm.NewEngine(setting.Database.Type.String(), dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create slave engine for dsn %q: %w", dsn, err)
|
|
}
|
|
if setting.Database.Type.IsMySQL() {
|
|
slaveEngine.Dialect().SetParams(map[string]string{"rowFormat": "DYNAMIC"})
|
|
}
|
|
slaveEngine.SetSchema(setting.Database.Schema)
|
|
slaveEngines = append(slaveEngines, slaveEngine)
|
|
}
|
|
|
|
policy := setting.BuildLoadBalancePolicy(&setting.Database, slaveEngines)
|
|
|
|
// Create the EngineGroup using the selected policy
|
|
group, err := xorm.NewEngineGroup(masterEngine, slaveEngines, policy)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create engine group: %w", err)
|
|
}
|
|
return engineGroupWrapper{group}, nil
|
|
}
|
|
|
|
type engineGroupWrapper struct {
|
|
*xorm.EngineGroup
|
|
}
|
|
|
|
func (w engineGroupWrapper) AddHook(hook contexts.Hook) bool {
|
|
w.EngineGroup.AddHook(hook)
|
|
return true
|
|
}
|
|
|
|
// SyncAllTables sync the schemas of all tables
|
|
func SyncAllTables() error {
|
|
sortedTables, err := sortBeans(tables, foreignKeySortInsert)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = x.StoreEngine("InnoDB").SyncWithOptions(xorm.SyncOptions{
|
|
WarnIfDatabaseColumnMissed: true,
|
|
}, sortedTables...)
|
|
return err
|
|
}
|
|
|
|
// InitEngine initializes the xorm EngineGroup and sets it as db.DefaultContext
|
|
func InitEngine(ctx context.Context) error {
|
|
xormEngine, err := newXORMEngineGroup()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to connect to database: %w", err)
|
|
}
|
|
// Try to cast to the concrete type to access diagnostic methods
|
|
if eng, ok := xormEngine.(engineGroupWrapper); ok {
|
|
eng.SetMapper(names.GonicMapper{})
|
|
// WARNING: for serv command, MUST remove the output to os.Stdout,
|
|
// so use a log file instead of printing to stdout.
|
|
eng.SetLogger(NewXORMLogger(setting.Database.LogSQL))
|
|
eng.ShowSQL(setting.Database.LogSQL)
|
|
eng.SetMaxOpenConns(setting.Database.MaxOpenConns)
|
|
eng.SetMaxIdleConns(setting.Database.MaxIdleConns)
|
|
eng.SetConnMaxLifetime(setting.Database.ConnMaxLifetime)
|
|
eng.SetConnMaxIdleTime(setting.Database.ConnMaxIdleTime)
|
|
eng.SetDefaultContext(ctx)
|
|
|
|
if setting.Database.SlowQueryThreshold > 0 {
|
|
eng.AddHook(&SlowQueryHook{
|
|
Threshold: setting.Database.SlowQueryThreshold,
|
|
Logger: log.GetLogger("xorm"),
|
|
})
|
|
}
|
|
|
|
errorLogger := log.GetLogger("xorm")
|
|
if setting.IsInTesting {
|
|
errorLogger = log.GetLogger(log.DEFAULT)
|
|
}
|
|
|
|
eng.AddHook(&ErrorQueryHook{
|
|
Logger: errorLogger,
|
|
})
|
|
|
|
eng.AddHook(&TracingHook{})
|
|
|
|
SetDefaultEngine(ctx, eng)
|
|
} else {
|
|
// Fallback: if type assertion fails, set default engine without extended diagnostics
|
|
SetDefaultEngine(ctx, xormEngine)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetDefaultEngine sets the default engine for db.
|
|
func SetDefaultEngine(ctx context.Context, eng Engine) {
|
|
masterEngine, err := GetMasterEngine(eng)
|
|
if err == nil {
|
|
x = masterEngine
|
|
}
|
|
|
|
DefaultContext = &Context{
|
|
Context: ctx,
|
|
e: eng,
|
|
}
|
|
}
|
|
|
|
// UnsetDefaultEngine closes and unsets the default engine
|
|
// We hope the SetDefaultEngine and UnsetDefaultEngine can be paired, but it's impossible now,
|
|
// there are many calls to InitEngine -> SetDefaultEngine directly to overwrite the `x` and DefaultContext without close
|
|
// Global database engine related functions are all racy and there is no graceful close right now.
|
|
func UnsetDefaultEngine() {
|
|
if x != nil {
|
|
_ = x.Close()
|
|
x = nil
|
|
}
|
|
DefaultContext = nil
|
|
}
|
|
|
|
// InitEngineWithMigration initializes a new xorm EngineGroup, runs migrations, and sets it as db.DefaultContext
|
|
// This function must never call .Sync() if the provided migration function fails.
|
|
// When called from the "doctor" command, the migration function is a version check
|
|
// that prevents the doctor from fixing anything in the database if the migration level
|
|
// is different from the expected value.
|
|
func InitEngineWithMigration(ctx context.Context, migrateFunc func(Engine) error) (err error) {
|
|
if err = InitEngine(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = x.Ping(); err != nil {
|
|
return err
|
|
}
|
|
|
|
preprocessDatabaseCollation(x)
|
|
|
|
// We have to run migrateFunc here in case the user is re-running installation on a previously created DB.
|
|
// If we do not then table schemas will be changed and there will be conflicts when the migrations run properly.
|
|
//
|
|
// Installation should only be being re-run if users want to recover an old database.
|
|
// However, we should think carefully about should we support re-install on an installed instance,
|
|
// as there may be other problems due to secret reinitialization.
|
|
if err = migrateFunc(x); err != nil {
|
|
return fmt.Errorf("migrate: %w", err)
|
|
}
|
|
|
|
if err = SyncAllTables(); err != nil {
|
|
return fmt.Errorf("sync database struct error: %w", err)
|
|
}
|
|
|
|
for _, initFunc := range initFuncs {
|
|
if err := initFunc(); err != nil {
|
|
return fmt.Errorf("initFunc failed: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// NamesToBean returns a list of beans given names
|
|
func NamesToBean(names ...string) ([]any, error) {
|
|
beans := []any{}
|
|
if len(names) == 0 {
|
|
beans = append(beans, tables...)
|
|
return beans, nil
|
|
}
|
|
// Map provided names to beans
|
|
beanMap := make(map[string]any)
|
|
for _, bean := range tables {
|
|
beanMap[strings.ToLower(reflect.Indirect(reflect.ValueOf(bean)).Type().Name())] = bean
|
|
beanMap[strings.ToLower(x.TableName(bean))] = bean
|
|
beanMap[strings.ToLower(x.TableName(bean, true))] = bean
|
|
}
|
|
|
|
gotBean := make(map[any]bool)
|
|
for _, name := range names {
|
|
bean, ok := beanMap[strings.ToLower(strings.TrimSpace(name))]
|
|
if !ok {
|
|
return nil, fmt.Errorf("no table found that matches: %s", name)
|
|
}
|
|
if !gotBean[bean] {
|
|
beans = append(beans, bean)
|
|
gotBean[bean] = true
|
|
}
|
|
}
|
|
return beans, nil
|
|
}
|
|
|
|
// DumpDatabase dumps all data from database using special SQL syntax to the file system.
|
|
func DumpDatabase(filePath, dbType string) error {
|
|
var tbs []*schemas.Table
|
|
for _, t := range tables {
|
|
t, err := x.TableInfo(t)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tbs = append(tbs, t)
|
|
}
|
|
|
|
type Version struct {
|
|
ID int64 `xorm:"pk autoincr"`
|
|
Version int64
|
|
}
|
|
t, err := x.TableInfo(&Version{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tbs = append(tbs, t)
|
|
|
|
if len(dbType) > 0 {
|
|
return x.DumpTablesToFile(tbs, filePath, schemas.DBType(dbType))
|
|
}
|
|
return x.DumpTablesToFile(tbs, filePath)
|
|
}
|
|
|
|
// MaxBatchInsertSize returns the table's max batch insert size
|
|
func MaxBatchInsertSize(bean any) int {
|
|
t, err := x.TableInfo(bean)
|
|
if err != nil {
|
|
return 50
|
|
}
|
|
return 999 / len(t.ColumnsSeq())
|
|
}
|
|
|
|
// IsTableNotEmpty returns true if the table has at least one record
|
|
func IsTableNotEmpty(beanOrTableName any) (bool, error) {
|
|
return x.Table(beanOrTableName).Exist()
|
|
}
|
|
|
|
// DeleteAllRecords deletes all records in the given table.
|
|
func DeleteAllRecords(tableName string) error {
|
|
_, err := x.Exec(fmt.Sprintf("DELETE FROM %s", tableName))
|
|
return err
|
|
}
|
|
|
|
// GetMaxID returns the maximum id in the table
|
|
func GetMaxID(beanOrTableName any) (maxID int64, err error) {
|
|
_, err = x.Select("MAX(id)").Table(beanOrTableName).Get(&maxID)
|
|
return maxID, err
|
|
}
|
|
|
|
func SetLogSQL(ctx context.Context, on bool) {
|
|
ctxEngine := GetEngine(ctx)
|
|
|
|
if sess, ok := ctxEngine.(*xorm.Session); ok {
|
|
sess.Engine().ShowSQL(on)
|
|
} else if wrapper, ok := ctxEngine.(engineGroupWrapper); ok {
|
|
// Handle engineGroupWrapper directly
|
|
wrapper.ShowSQL(on)
|
|
} else if masterEngine, err := GetMasterEngine(ctxEngine); err == nil {
|
|
masterEngine.ShowSQL(on)
|
|
}
|
|
}
|
|
|
|
type TracingHook struct{}
|
|
|
|
var _ contexts.Hook = &TracingHook{}
|
|
|
|
type sqlTask struct{}
|
|
|
|
func (TracingHook) BeforeProcess(c *contexts.ContextHook) (context.Context, error) {
|
|
ctx, task := trace.NewTask(c.Ctx, "sql")
|
|
ctx = context.WithValue(ctx, sqlTask{}, task)
|
|
trace.Log(ctx, "query", c.SQL)
|
|
trace.Logf(ctx, "args", "%v", c.Args)
|
|
return ctx, nil
|
|
}
|
|
|
|
func (TracingHook) AfterProcess(c *contexts.ContextHook) error {
|
|
if c.Result != nil {
|
|
if rowsAffected, err := c.Result.RowsAffected(); err == nil {
|
|
trace.Logf(c.Ctx, "rows affected", "%d", rowsAffected)
|
|
}
|
|
if lastID, err := c.Result.LastInsertId(); err == nil {
|
|
trace.Logf(c.Ctx, "last insert id", "%d", lastID)
|
|
}
|
|
}
|
|
|
|
c.Ctx.Value(sqlTask{}).(*trace.Task).End()
|
|
return nil
|
|
}
|
|
|
|
type SlowQueryHook struct {
|
|
Threshold time.Duration
|
|
Logger log.Logger
|
|
}
|
|
|
|
var _ contexts.Hook = &SlowQueryHook{}
|
|
|
|
func (SlowQueryHook) BeforeProcess(c *contexts.ContextHook) (context.Context, error) {
|
|
return c.Ctx, nil
|
|
}
|
|
|
|
func (h *SlowQueryHook) AfterProcess(c *contexts.ContextHook) error {
|
|
if c.ExecuteTime >= h.Threshold {
|
|
h.Logger.Log(8, log.WARN, "[Slow SQL Query] %s %v - %v", c.SQL, c.Args, c.ExecuteTime)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type ErrorQueryHook struct {
|
|
Logger log.Logger
|
|
}
|
|
|
|
var _ contexts.Hook = &ErrorQueryHook{}
|
|
|
|
func (ErrorQueryHook) BeforeProcess(c *contexts.ContextHook) (context.Context, error) {
|
|
return c.Ctx, nil
|
|
}
|
|
|
|
func (h *ErrorQueryHook) AfterProcess(c *contexts.ContextHook) error {
|
|
if c.Err != nil && !errors.Is(c.Err, context.Canceled) {
|
|
h.Logger.Log(8, log.ERROR, "[Error SQL Query] %s %v - %v", c.SQL, c.Args, c.Err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetMasterEngine extracts the master xorm.Engine from the provided xorm.Engine.
|
|
// This handles both direct xorm.Engine cases and engines that implement a Master() method.
|
|
func GetMasterEngine(x Engine) (*xorm.Engine, error) {
|
|
if getter, ok := x.(interface{ Master() *xorm.Engine }); ok {
|
|
return getter.Master(), nil
|
|
}
|
|
|
|
engine, ok := x.(*xorm.Engine)
|
|
if !ok {
|
|
return nil, fmt.Errorf("unsupported engine type: %T", x)
|
|
}
|
|
|
|
return engine, nil
|
|
}
|
|
|
|
// GetTableNames returns the table name of all registered models.
|
|
func GetTableNames() container.Set[string] {
|
|
names := make(container.Set[string])
|
|
for _, table := range tables {
|
|
names.Add(x.TableName(table))
|
|
}
|
|
return names
|
|
}
|