309 lines
8.1 KiB
Go
309 lines
8.1 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
|
|
"git.mchus.pro/mchus/quoteforge/internal/appstate"
|
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
|
"github.com/google/uuid"
|
|
"gorm.io/driver/mysql"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
type configRow struct {
|
|
ID uint
|
|
UUID string
|
|
OwnerUsername string
|
|
Name string
|
|
ProjectUUID *string
|
|
}
|
|
|
|
type migrationAction struct {
|
|
ConfigID uint
|
|
ConfigUUID string
|
|
ConfigName string
|
|
OwnerUsername string
|
|
TargetProjectName string
|
|
CurrentProject string
|
|
NeedCreateProject bool
|
|
NeedReactivate bool
|
|
}
|
|
|
|
func main() {
|
|
defaultLocalDBPath, err := appstate.ResolveDBPath("")
|
|
if err != nil {
|
|
log.Fatalf("failed to resolve default local SQLite path: %v", err)
|
|
}
|
|
localDBPath := flag.String("localdb", defaultLocalDBPath, "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
|
|
apply := flag.Bool("apply", false, "apply migration (default is preview only)")
|
|
yes := flag.Bool("yes", false, "skip interactive confirmation (works only with -apply)")
|
|
flag.Parse()
|
|
|
|
local, err := localdb.New(*localDBPath)
|
|
if err != nil {
|
|
log.Fatalf("failed to initialize local database: %v", err)
|
|
}
|
|
if !local.HasSettings() {
|
|
log.Fatalf("SQLite connection settings are not configured. Run qfs setup first.")
|
|
}
|
|
dsn, err := local.GetDSN()
|
|
if err != nil {
|
|
log.Fatalf("failed to build DSN from SQLite settings: %v", err)
|
|
}
|
|
dbUser := strings.TrimSpace(local.GetDBUser())
|
|
|
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
if err != nil {
|
|
log.Fatalf("failed to connect database: %v", err)
|
|
}
|
|
|
|
if err := ensureProjectsTable(db); err != nil {
|
|
log.Fatalf("precheck failed: %v", err)
|
|
}
|
|
|
|
actions, existingProjects, err := buildPlan(db, dbUser)
|
|
if err != nil {
|
|
log.Fatalf("failed to build migration plan: %v", err)
|
|
}
|
|
|
|
printPlan(actions)
|
|
if len(actions) == 0 {
|
|
fmt.Println("Nothing to migrate.")
|
|
return
|
|
}
|
|
|
|
if !*apply {
|
|
fmt.Println("\nPreview complete. Re-run with -apply to execute.")
|
|
return
|
|
}
|
|
|
|
if !*yes {
|
|
ok, confirmErr := askForConfirmation()
|
|
if confirmErr != nil {
|
|
log.Fatalf("confirmation failed: %v", confirmErr)
|
|
}
|
|
if !ok {
|
|
fmt.Println("Aborted.")
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := executePlan(db, actions, existingProjects); err != nil {
|
|
log.Fatalf("migration failed: %v", err)
|
|
}
|
|
|
|
fmt.Println("Migration completed successfully.")
|
|
}
|
|
|
|
func ensureProjectsTable(db *gorm.DB) error {
|
|
var count int64
|
|
if err := db.Raw("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'qt_projects'").Scan(&count).Error; err != nil {
|
|
return fmt.Errorf("checking qt_projects table: %w", err)
|
|
}
|
|
if count == 0 {
|
|
return fmt.Errorf("table qt_projects does not exist; run migration 009_add_projects.sql first")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func buildPlan(db *gorm.DB, fallbackOwner string) ([]migrationAction, map[string]*models.Project, error) {
|
|
var configs []configRow
|
|
if err := db.Table("qt_configurations").
|
|
Select("id, uuid, owner_username, name, project_uuid").
|
|
Find(&configs).Error; err != nil {
|
|
return nil, nil, fmt.Errorf("load configurations: %w", err)
|
|
}
|
|
|
|
codeRegex := regexp.MustCompile(`^(OPS-[0-9]{4})`)
|
|
owners := make(map[string]struct{})
|
|
projectNames := make(map[string]struct{})
|
|
type candidate struct {
|
|
config configRow
|
|
code string
|
|
owner string
|
|
}
|
|
candidates := make([]candidate, 0)
|
|
|
|
for _, cfg := range configs {
|
|
match := codeRegex.FindStringSubmatch(strings.TrimSpace(cfg.Name))
|
|
if len(match) < 2 {
|
|
continue
|
|
}
|
|
owner := strings.TrimSpace(cfg.OwnerUsername)
|
|
if owner == "" {
|
|
owner = strings.TrimSpace(fallbackOwner)
|
|
}
|
|
if owner == "" {
|
|
continue
|
|
}
|
|
code := match[1]
|
|
owners[owner] = struct{}{}
|
|
projectNames[code] = struct{}{}
|
|
candidates = append(candidates, candidate{config: cfg, code: code, owner: owner})
|
|
}
|
|
|
|
ownerList := setKeys(owners)
|
|
nameList := setKeys(projectNames)
|
|
existingProjects := make(map[string]*models.Project)
|
|
if len(ownerList) > 0 && len(nameList) > 0 {
|
|
var projects []models.Project
|
|
if err := db.Where("owner_username IN ? AND name IN ?", ownerList, nameList).Find(&projects).Error; err != nil {
|
|
return nil, nil, fmt.Errorf("load existing projects: %w", err)
|
|
}
|
|
for i := range projects {
|
|
p := projects[i]
|
|
existingProjects[projectKey(p.OwnerUsername, derefString(p.Name))] = &p
|
|
}
|
|
}
|
|
|
|
actions := make([]migrationAction, 0)
|
|
for _, c := range candidates {
|
|
key := projectKey(c.owner, c.code)
|
|
existing := existingProjects[key]
|
|
|
|
currentProject := ""
|
|
if c.config.ProjectUUID != nil {
|
|
currentProject = *c.config.ProjectUUID
|
|
}
|
|
|
|
if existing != nil && currentProject == existing.UUID {
|
|
continue
|
|
}
|
|
|
|
action := migrationAction{
|
|
ConfigID: c.config.ID,
|
|
ConfigUUID: c.config.UUID,
|
|
ConfigName: c.config.Name,
|
|
OwnerUsername: c.owner,
|
|
TargetProjectName: c.code,
|
|
CurrentProject: currentProject,
|
|
}
|
|
if existing == nil {
|
|
action.NeedCreateProject = true
|
|
} else if !existing.IsActive {
|
|
action.NeedReactivate = true
|
|
}
|
|
actions = append(actions, action)
|
|
}
|
|
|
|
return actions, existingProjects, nil
|
|
}
|
|
|
|
func printPlan(actions []migrationAction) {
|
|
createCount := 0
|
|
reactivateCount := 0
|
|
for _, a := range actions {
|
|
if a.NeedCreateProject {
|
|
createCount++
|
|
}
|
|
if a.NeedReactivate {
|
|
reactivateCount++
|
|
}
|
|
}
|
|
|
|
fmt.Printf("Planned actions: %d\n", len(actions))
|
|
fmt.Printf("Projects to create: %d\n", createCount)
|
|
fmt.Printf("Projects to reactivate: %d\n", reactivateCount)
|
|
fmt.Println("\nDetails:")
|
|
|
|
for _, a := range actions {
|
|
extra := ""
|
|
if a.NeedCreateProject {
|
|
extra = " [create project]"
|
|
} else if a.NeedReactivate {
|
|
extra = " [reactivate project]"
|
|
}
|
|
current := a.CurrentProject
|
|
if current == "" {
|
|
current = "NULL"
|
|
}
|
|
fmt.Printf("- %s | owner=%s | \"%s\" | project: %s -> %s%s\n",
|
|
a.ConfigUUID, a.OwnerUsername, a.ConfigName, current, a.TargetProjectName, extra)
|
|
}
|
|
}
|
|
|
|
func askForConfirmation() (bool, error) {
|
|
fmt.Print("\nApply these changes? type 'yes' to continue: ")
|
|
reader := bufio.NewReader(os.Stdin)
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return strings.EqualFold(strings.TrimSpace(line), "yes"), nil
|
|
}
|
|
|
|
func executePlan(db *gorm.DB, actions []migrationAction, existingProjects map[string]*models.Project) error {
|
|
return db.Transaction(func(tx *gorm.DB) error {
|
|
projectCache := make(map[string]*models.Project, len(existingProjects))
|
|
for k, v := range existingProjects {
|
|
cp := *v
|
|
projectCache[k] = &cp
|
|
}
|
|
|
|
for _, action := range actions {
|
|
key := projectKey(action.OwnerUsername, action.TargetProjectName)
|
|
project := projectCache[key]
|
|
if project == nil {
|
|
project = &models.Project{
|
|
UUID: uuid.NewString(),
|
|
OwnerUsername: action.OwnerUsername,
|
|
Code: action.TargetProjectName,
|
|
Name: ptrString(action.TargetProjectName),
|
|
IsActive: true,
|
|
IsSystem: false,
|
|
}
|
|
if err := tx.Create(project).Error; err != nil {
|
|
return fmt.Errorf("create project %s for owner %s: %w", action.TargetProjectName, action.OwnerUsername, err)
|
|
}
|
|
projectCache[key] = project
|
|
} else if !project.IsActive {
|
|
if err := tx.Model(&models.Project{}).Where("uuid = ?", project.UUID).Update("is_active", true).Error; err != nil {
|
|
return fmt.Errorf("reactivate project %s (%s): %w", derefString(project.Name), project.UUID, err)
|
|
}
|
|
project.IsActive = true
|
|
}
|
|
|
|
if err := tx.Table("qt_configurations").Where("id = ?", action.ConfigID).Update("project_uuid", project.UUID).Error; err != nil {
|
|
return fmt.Errorf("move configuration %s to project %s: %w", action.ConfigUUID, project.UUID, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func setKeys(set map[string]struct{}) []string {
|
|
keys := make([]string, 0, len(set))
|
|
for k := range set {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
func projectKey(owner, name string) string {
|
|
return owner + "||" + name
|
|
}
|
|
|
|
func derefString(value *string) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return *value
|
|
}
|
|
|
|
func ptrString(value string) *string {
|
|
return &value
|
|
}
|