Renamed module path git.mchus.pro/mchus/quoteforge → git.mchus.pro/mchus/priceforge, renamed package quoteforge → priceforge, moved binary from cmd/qfs to cmd/pfs.
228 lines
5.5 KiB
Go
228 lines
5.5 KiB
Go
package models
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
mysqlDriver "github.com/go-sql-driver/mysql"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type SQLSchemaMigration struct {
|
|
ID uint `gorm:"primaryKey;autoIncrement"`
|
|
Filename string `gorm:"size:255;uniqueIndex;not null"`
|
|
AppliedAt time.Time `gorm:"autoCreateTime"`
|
|
}
|
|
|
|
func (SQLSchemaMigration) TableName() string {
|
|
return "qt_schema_migrations"
|
|
}
|
|
|
|
// NeedsSQLMigrations reports whether at least one SQL migration from migrationsDir
|
|
// is not yet recorded in qt_schema_migrations.
|
|
func NeedsSQLMigrations(db *gorm.DB, migrationsDir string) (bool, error) {
|
|
files, err := listSQLMigrationFiles(migrationsDir)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if len(files) == 0 {
|
|
return false, nil
|
|
}
|
|
|
|
// If tracking table does not exist yet, migrations are required.
|
|
if !db.Migrator().HasTable(&SQLSchemaMigration{}) {
|
|
return true, nil
|
|
}
|
|
|
|
var count int64
|
|
if err := db.Model(&SQLSchemaMigration{}).Where("filename IN ?", files).Count(&count).Error; err != nil {
|
|
return false, fmt.Errorf("check applied migrations: %w", err)
|
|
}
|
|
|
|
return count < int64(len(files)), nil
|
|
}
|
|
|
|
// RunSQLMigrations applies SQL files from migrationsDir once and records them in qt_schema_migrations.
|
|
// Local SQLite-only scripts are skipped automatically.
|
|
func RunSQLMigrations(db *gorm.DB, migrationsDir string) error {
|
|
if err := ensureSQLMigrationsTable(db); err != nil {
|
|
return fmt.Errorf("migrate qt_schema_migrations table: %w", err)
|
|
}
|
|
|
|
files, err := listSQLMigrationFiles(migrationsDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, filename := range files {
|
|
var count int64
|
|
if err := db.Model(&SQLSchemaMigration{}).Where("filename = ?", filename).Count(&count).Error; err != nil {
|
|
return fmt.Errorf("check migration %s: %w", filename, err)
|
|
}
|
|
if count > 0 {
|
|
continue
|
|
}
|
|
|
|
path := filepath.Join(migrationsDir, filename)
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return fmt.Errorf("read migration %s: %w", filename, err)
|
|
}
|
|
|
|
statements := splitSQLStatements(string(content))
|
|
if len(statements) == 0 {
|
|
if err := db.Create(&SQLSchemaMigration{Filename: filename}).Error; err != nil {
|
|
return fmt.Errorf("record empty migration %s: %w", filename, err)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if err := executeMigrationStatements(db, filename, statements); err != nil {
|
|
return err
|
|
}
|
|
if err := db.Create(&SQLSchemaMigration{Filename: filename}).Error; err != nil {
|
|
return fmt.Errorf("record migration %s: %w", filename, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// IsMigrationPermissionError returns true if err indicates insufficient privileges
|
|
// to create/alter/read migration metadata or target schema objects.
|
|
func IsMigrationPermissionError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
|
|
var mysqlErr *mysqlDriver.MySQLError
|
|
if errors.As(err, &mysqlErr) {
|
|
switch mysqlErr.Number {
|
|
case 1044, 1045, 1142, 1143, 1227:
|
|
return true
|
|
}
|
|
}
|
|
|
|
lower := strings.ToLower(err.Error())
|
|
patterns := []string{
|
|
"command denied to user",
|
|
"access denied for user",
|
|
"permission denied",
|
|
"insufficient privilege",
|
|
"sqlstate 42000",
|
|
}
|
|
for _, pattern := range patterns {
|
|
if strings.Contains(lower, pattern) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func ensureSQLMigrationsTable(db *gorm.DB) error {
|
|
stmt := `
|
|
CREATE TABLE IF NOT EXISTS qt_schema_migrations (
|
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
filename VARCHAR(255) NOT NULL UNIQUE,
|
|
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);`
|
|
return db.Exec(stmt).Error
|
|
}
|
|
|
|
func executeMigrationStatements(db *gorm.DB, filename string, statements []string) error {
|
|
for _, stmt := range statements {
|
|
if err := db.Exec(stmt).Error; err != nil {
|
|
if isIgnorableMigrationError(err.Error()) {
|
|
continue
|
|
}
|
|
return fmt.Errorf("exec migration %s statement %q: %w", filename, stmt, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isSQLiteOnlyMigration(filename string) bool {
|
|
lower := strings.ToLower(filename)
|
|
return strings.Contains(lower, "local_")
|
|
}
|
|
|
|
func isIgnorableMigrationError(message string) bool {
|
|
lower := strings.ToLower(message)
|
|
ignorable := []string{
|
|
"duplicate column name",
|
|
"duplicate key name",
|
|
"already exists",
|
|
"can't create table",
|
|
"duplicate foreign key constraint name",
|
|
"errno 121",
|
|
}
|
|
for _, pattern := range ignorable {
|
|
if strings.Contains(lower, pattern) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func splitSQLStatements(script string) []string {
|
|
scanner := bufio.NewScanner(strings.NewReader(script))
|
|
scanner.Buffer(make([]byte, 1024), 1024*1024)
|
|
|
|
lines := make([]string, 0, 128)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(line, "--") {
|
|
continue
|
|
}
|
|
lines = append(lines, scanner.Text())
|
|
}
|
|
|
|
combined := strings.Join(lines, "\n")
|
|
raw := strings.Split(combined, ";")
|
|
stmts := make([]string, 0, len(raw))
|
|
for _, stmt := range raw {
|
|
trimmed := strings.TrimSpace(stmt)
|
|
if trimmed == "" {
|
|
continue
|
|
}
|
|
stmts = append(stmts, trimmed)
|
|
}
|
|
return stmts
|
|
}
|
|
|
|
func listSQLMigrationFiles(migrationsDir string) ([]string, error) {
|
|
entries, err := os.ReadDir(migrationsDir)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("read migrations dir %s: %w", migrationsDir, err)
|
|
}
|
|
|
|
files := make([]string, 0, len(entries))
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
name := entry.Name()
|
|
if !strings.HasSuffix(strings.ToLower(name), ".sql") {
|
|
continue
|
|
}
|
|
if isSQLiteOnlyMigration(name) {
|
|
continue
|
|
}
|
|
files = append(files, name)
|
|
}
|
|
sort.Strings(files)
|
|
return files, nil
|
|
}
|