Convert db backend to use Gorm, with basis for support

for Mysql and Postgres in addition to existing Sqlite
This commit is contained in:
Jamie Curnow
2023-05-26 11:04:43 +10:00
parent b4e5b8b6db
commit 29990110b1
93 changed files with 1215 additions and 3075 deletions

View File

@ -0,0 +1,80 @@
package database
import (
"fmt"
"strings"
"npm/internal/config"
"npm/internal/logger"
"github.com/glebarez/sqlite"
"github.com/rotisserie/eris"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
var dbInstance *gorm.DB
// NewDB creates a new connection
func NewDB() {
logger.Info("Creating new DB instance using %s", strings.ToLower(config.Configuration.DB.Driver))
db, err := connect()
if err != nil {
logger.Error("DatabaseConnectError", err)
} else if db != nil {
dbInstance = db
}
}
// GetDB returns an existing or new instance
func GetDB() *gorm.DB {
if dbInstance == nil {
NewDB()
}
return dbInstance
}
func connect() (*gorm.DB, error) {
var d gorm.Dialector
dsn := config.Configuration.DB.GetGormConnectURL()
switch strings.ToLower(config.Configuration.DB.Driver) {
case config.DatabaseSqlite:
// autocreate(dsn)
d = sqlite.Open(dsn)
case config.DatabasePostgres:
d = postgres.Open(dsn)
case config.DatabaseMysql:
d = mysql.Open(dsn)
default:
return nil, eris.New(fmt.Sprintf("Database driver %s is not supported. Valid options are: %s, %s or %s", config.Configuration.DB.Driver, config.DatabaseSqlite, config.DatabasePostgres, config.DatabaseMysql))
}
return gorm.Open(d, &gorm.Config{
// see: https://gorm.io/docs/gorm_config.html
NamingStrategy: schema.NamingStrategy{
SingularTable: true,
NoLowerCase: true,
},
PrepareStmt: false,
})
}
/*
func autocreate(dbFile string) {
if _, err := os.Stat(dbFile); os.IsNotExist(err) {
// Create it
logger.Info("Creating Sqlite DB: %s", dbFile)
// nolint: gosec
_, err = os.Create(dbFile)
if err != nil {
logger.Error("FileCreateError", err)
}
}
}
*/

View File

@ -1,46 +1,8 @@
package database
import (
"fmt"
"strings"
"npm/internal/errors"
"npm/internal/model"
"npm/internal/util"
)
const (
// DateFormat for DateFormat
DateFormat = "2006-01-02"
// DateTimeFormat for DateTimeFormat
DateTimeFormat = "2006-01-02T15:04:05"
)
// GetByQuery returns a row given a query, populating the model given
func GetByQuery(model interface{}, query string, params []interface{}) error {
db := GetInstance()
if db != nil {
err := db.Get(model, query, params...)
return err
}
return errors.ErrDatabaseUnavailable
}
// BuildOrderBySQL takes a `Sort` slice and constructs a query fragment
func BuildOrderBySQL(columns []string, sort *[]model.Sort) (string, []model.Sort) {
var sortStrings []string
var newSort []model.Sort
for _, sortItem := range *sort {
if util.SliceContainsItem(columns, sortItem.Field) {
sortStrings = append(sortStrings, fmt.Sprintf("`%s` %s", sortItem.Field, sortItem.Direction))
newSort = append(newSort, sortItem)
}
}
if len(sortStrings) > 0 {
return fmt.Sprintf("ORDER BY %s", strings.Join(sortStrings, ", ")), newSort
}
return "", newSort
}

View File

@ -1,203 +1,44 @@
package database
import (
"database/sql"
"fmt"
"io/fs"
"path"
"path/filepath"
"strings"
"sync"
"time"
"net/url"
"npm/embed"
"npm/internal/config"
"npm/internal/logger"
"npm/internal/util"
"github.com/jmoiron/sqlx"
"github.com/rotisserie/eris"
"github.com/amacneil/dbmate/v2/pkg/dbmate"
_ "github.com/amacneil/dbmate/v2/pkg/driver/postgres"
_ "github.com/amacneil/dbmate/v2/pkg/driver/sqlite"
)
// MigrationConfiguration options for the migrator.
type MigrationConfiguration struct {
Table string `json:"table"`
mux sync.Mutex
}
// Default migrator configuration
var mConfiguration = MigrationConfiguration{
Table: "migration",
}
// ConfigureMigrator and will return error if missing required fields.
func ConfigureMigrator(c *MigrationConfiguration) error {
// ensure updates to the config are atomic
mConfiguration.mux.Lock()
defer mConfiguration.mux.Unlock()
if c == nil {
return eris.Errorf("a non nil Configuration is mandatory")
}
if strings.TrimSpace(c.Table) != "" {
mConfiguration.Table = c.Table
}
mConfiguration.Table = c.Table
return nil
}
type afterMigrationComplete func()
// Migrate will perform the migration from start to finish
// Migrate will bring the db up to date
func Migrate(followup afterMigrationComplete) bool {
logger.Info("Migration: Started")
dbURL := config.Configuration.DB.GetDBMateConnectURL()
u, _ := url.Parse(dbURL)
db := dbmate.New(u)
db.FS = embed.MigrationFiles
db.MigrationsDir = []string{fmt.Sprintf("./migrations/%s", config.Configuration.DB.GetDriver())}
// Try to connect to the database sleeping for 15 seconds in between
var db *sqlx.DB
for {
db = GetInstance()
if db == nil {
logger.Warn("Database is unavailable for migration, retrying in 15 seconds")
time.Sleep(15 * time.Second)
} else {
break
}
}
// Check for migration table existence
if !tableExists(db, mConfiguration.Table) {
err := createMigrationTable(db)
if err != nil {
logger.Error("MigratorError", err)
return false
}
logger.Info("Migration: Migration Table created")
}
// DO MIGRATION
migrationCount, migrateErr := performFileMigrations(db)
if migrateErr != nil {
logger.Error("MigratorError", migrateErr)
}
if migrateErr == nil {
logger.Info("Migration: Completed %v migration files", migrationCount)
followup()
return true
}
return false
}
// createMigrationTable performs a query to create the migration table
// with the name specified in the configuration
func createMigrationTable(db *sqlx.DB) error {
logger.Info("Migration: Creating Migration Table: %v", mConfiguration.Table)
// nolint:lll
query := fmt.Sprintf("CREATE TABLE IF NOT EXISTS `%v` (filename TEXT PRIMARY KEY, migrated_on INTEGER NOT NULL DEFAULT 0)", mConfiguration.Table)
_, err := db.Exec(query)
return err
}
// tableExists will check the database for the existence of the specified table.
func tableExists(db *sqlx.DB, tableName string) bool {
query := `SELECT CASE name WHEN $1 THEN true ELSE false END AS found FROM sqlite_master WHERE type='table' AND name = $1`
row := db.QueryRowx(query, tableName)
if row == nil {
logger.Error("MigratorError", eris.Errorf("Cannot check if table exists, no row returned: %v", tableName))
return false
}
var exists *bool
if err := row.Scan(&exists); err != nil {
if err == sql.ErrNoRows {
return false
}
logger.Error("MigratorError", err)
return false
}
return *exists
}
// performFileMigrations will perform the actual migration,
// importing files and updating the database with the rows imported.
func performFileMigrations(db *sqlx.DB) (int, error) {
var importedCount = 0
// Grab a list of previously ran migrations from the database:
previousMigrations, prevErr := getPreviousMigrations(db)
if prevErr != nil {
return importedCount, prevErr
}
// List up the ".sql" files on disk
err := fs.WalkDir(embed.MigrationFiles, ".", func(file string, d fs.DirEntry, err error) error {
if !d.IsDir() {
shortFile := filepath.Base(file)
// Check if this file already exists in the previous migrations
// and if so, ignore it
if util.SliceContainsItem(previousMigrations, shortFile) {
return nil
}
logger.Info("Migration: Importing %v", shortFile)
sqlContents, ioErr := embed.MigrationFiles.ReadFile(path.Clean(file))
if ioErr != nil {
return ioErr
}
sqlString := string(sqlContents)
tx := db.MustBegin()
if _, execErr := tx.Exec(sqlString); execErr != nil {
return execErr
}
if commitErr := tx.Commit(); commitErr != nil {
return commitErr
}
if markErr := markMigrationSuccessful(db, shortFile); markErr != nil {
return markErr
}
importedCount++
}
return nil
})
return importedCount, err
}
// getPreviousMigrations will query the migration table for names
// of migrations we can ignore because they should have already
// been imported
func getPreviousMigrations(db *sqlx.DB) ([]string, error) {
var existingMigrations []string
// nolint:gosec
query := fmt.Sprintf("SELECT filename FROM `%v` ORDER BY filename", mConfiguration.Table)
rows, err := db.Queryx(query)
migrations, err := db.FindMigrations()
if err != nil {
if err == sql.ErrNoRows {
return existingMigrations, nil
}
return existingMigrations, err
logger.Error("MigrationError", err)
return false
}
for rows.Next() {
var filename *string
err := rows.Scan(&filename)
if err != nil {
return existingMigrations, err
}
existingMigrations = append(existingMigrations, *filename)
for _, m := range migrations {
logger.Debug("%s: %s", m.Version, m.FilePath)
}
return existingMigrations, nil
}
err = db.CreateAndMigrate()
if err != nil {
logger.Error("MigrationError", err)
return false
}
// markMigrationSuccessful will add a row to the migration table
func markMigrationSuccessful(db *sqlx.DB, filename string) error {
// nolint:gosec
query := fmt.Sprintf("INSERT INTO `%v` (filename) VALUES ($1)", mConfiguration.Table)
_, err := db.Exec(query, filename)
return err
followup()
return true
}

View File

@ -1,37 +0,0 @@
package database
import (
"database/sql"
"npm/internal/config"
"npm/internal/errors"
"npm/internal/logger"
)
// CheckSetup Quick check by counting the number of users in the database
func CheckSetup() {
query := `SELECT COUNT(*) FROM "user" WHERE is_deleted = $1 AND is_disabled = $1 AND is_system = $1`
db := GetInstance()
if db != nil {
row := db.QueryRowx(query, false)
var totalRows int
queryErr := row.Scan(&totalRows)
if queryErr != nil && queryErr != sql.ErrNoRows {
logger.Error("SetupError", queryErr)
return
}
if totalRows == 0 {
logger.Warn("No users found, starting in Setup Mode")
} else {
config.IsSetup = true
logger.Info("Application is setup")
}
if config.ErrorReporting {
logger.Warn("Error reporting is enabled - Application Errors WILL be sent to Sentry, you can disable this in the Settings interface")
}
} else {
logger.Error("DatabaseError", errors.ErrDatabaseUnavailable)
}
}

View File

@ -1,74 +0,0 @@
package database
import (
"fmt"
"os"
"npm/internal/config"
"npm/internal/logger"
"github.com/jmoiron/sqlx"
// Blank import for Sqlite
_ "modernc.org/sqlite"
)
var dbInstance *sqlx.DB
// NewDB creates a new connection
func NewDB() {
logger.Info("Creating new DB instance")
db := SqliteDB()
if db != nil {
dbInstance = db
}
}
// GetInstance returns an existing or new instance
func GetInstance() *sqlx.DB {
if dbInstance == nil {
NewDB()
} else if err := dbInstance.Ping(); err != nil {
NewDB()
}
return dbInstance
}
// SqliteDB Create sqlite client
func SqliteDB() *sqlx.DB {
dbFile := fmt.Sprintf("%s/nginxproxymanager.db", config.Configuration.DataFolder)
autocreate(dbFile)
db, err := sqlx.Open("sqlite", dbFile)
if err != nil {
logger.Error("SqliteError", err)
return nil
}
return db
}
// Commit will close and reopen the db file
func Commit() *sqlx.DB {
if dbInstance != nil {
err := dbInstance.Close()
if err != nil {
logger.Error("DatabaseCloseError", err)
}
}
NewDB()
return dbInstance
}
func autocreate(dbFile string) {
if _, err := os.Stat(dbFile); os.IsNotExist(err) {
// Create it
logger.Info("Creating Sqlite DB: %s", dbFile)
// nolint: gosec
_, err = os.Create(dbFile)
if err != nil {
logger.Error("FileCreateError", err)
}
Commit()
}
}