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 }