package database import ( "context" "errors" "fmt" "strings" "time" "git.iwork-ai.com/xdc/iqudo-top1/internal/config" "git.iwork-ai.com/xdc/iqudo-top1/internal/model" "go.uber.org/zap" "gorm.io/driver/mysql" "gorm.io/gorm" gormlogger "gorm.io/gorm/logger" ) func Open(cfg config.DatabaseConfig, log *zap.Logger) (*gorm.DB, error) { db, err := gorm.Open(mysql.Open(cfg.DSN()), &gorm.Config{ Logger: newGORMLogger(log.Named("gorm"), gormlogger.Warn), }) if err != nil { return nil, fmt.Errorf("open mysql: %w", err) } sqlDB, err := db.DB() if err != nil { return nil, fmt.Errorf("get sql database: %w", err) } sqlDB.SetMaxIdleConns(cfg.MaxIdleConnections) sqlDB.SetMaxOpenConns(cfg.MaxOpenConnections) sqlDB.SetConnMaxLifetime(cfg.ConnectionMaxLifetime) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := sqlDB.PingContext(ctx); err != nil { return nil, fmt.Errorf("ping mysql: %w", err) } return db, nil } // AutoMigrate synchronizes the application schema from the GORM models. // GORM only adds missing tables, columns and indexes; it does not drop // existing columns, which keeps production data safe during application // upgrades. func AutoMigrate(db *gorm.DB) error { // The application schema intentionally has no foreign keys. Remove any // constraints left by older installations before dropping legacy tables. if err := dropAllForeignKeys(db); err != nil { return err } if err := dropUnusedLegacyTables(db); err != nil { return err } if err := prepareSingleVersionSchema(db); err != nil { return err } return db.AutoMigrate( &model.Tenant{}, &model.User{}, &model.Role{}, &model.TenantMember{}, &model.Scenario{}, &model.ScenarioField{}, &model.ScenarioRule{}, &model.SOP{}, &model.SOPNode{}, &model.SOPEdge{}, &model.KnowledgeItem{}, &model.KnowledgeRelation{}, &model.SOPRun{}, &model.SOPRunEvent{}, &model.PublicRunSession{}, &model.SOPFeedback{}, &model.AuditLog{}, &model.MultiTableOutbox{}, &model.RefreshToken{}, ) } // dropUnusedLegacyTables removes schema objects that are no longer part of // the scenario knowledge graph. Drop the version table before its parent so // this remains safe for databases that still have a historical constraint. func dropUnusedLegacyTables(db *gorm.DB) error { for _, table := range []string{"knowledge_card_versions", "knowledge_cards"} { if !db.Migrator().HasTable(table) { continue } if err := db.Exec("DROP TABLE " + quoteIdentifier(table)).Error; err != nil { return fmt.Errorf("drop unused table %s: %w", table, err) } } return nil } // prepareSingleVersionSchema performs the data-preserving part of the SOP // version removal. GORM does not rename or drop columns, so existing installs // need their current published version copied onto the single SOP graph. func prepareSingleVersionSchema(db *gorm.DB) error { if !db.Migrator().HasTable("sops") { return nil } // Foreign keys are intentionally not part of the application schema. Drop // historical constraints before GORM rebuilds any tables during migration; // old version references may contain rows that no longer have a parent. if err := dropAllForeignKeys(db); err != nil { return err } if !db.Migrator().HasColumn("sops", "start_node_key") { if err := db.Exec("ALTER TABLE sops ADD COLUMN start_node_key VARCHAR(64) NOT NULL DEFAULT 'start'").Error; err != nil { return fmt.Errorf("add sops.start_node_key: %w", err) } } if db.Migrator().HasTable("sop_versions") { if err := db.Exec("UPDATE sops s JOIN sop_versions sv ON sv.id = (SELECT MAX(sv2.id) FROM sop_versions sv2 WHERE sv2.sop_id = s.id AND sv2.status = 'published') SET s.start_node_key = COALESCE(NULLIF(sv.start_node_key, ''), NULLIF(s.start_node_key, ''), 'start')").Error; err != nil { return fmt.Errorf("copy SOP start nodes: %w", err) } } for _, table := range []string{"sop_nodes", "sop_edges", "sop_runs"} { if !db.Migrator().HasTable(table) { continue } if !db.Migrator().HasColumn(table, "sop_id") { if err := db.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN sop_id BIGINT UNSIGNED NULL", table)).Error; err != nil { return fmt.Errorf("add %s.sop_id: %w", table, err) } } if db.Migrator().HasTable("sop_versions") && db.Migrator().HasColumn(table, "sop_version_id") { if err := db.Exec(fmt.Sprintf("UPDATE %s x JOIN sop_versions sv ON sv.id = x.sop_version_id SET x.sop_id = sv.sop_id", table)).Error; err != nil { return fmt.Errorf("copy %s SOP references: %w", table, err) } } } if db.Migrator().HasTable("sop_versions") { if err := db.Exec("DELETE n FROM sop_nodes n JOIN sop_versions sv ON sv.id = n.sop_version_id WHERE sv.status <> 'published' OR sv.id <> (SELECT MAX(sv2.id) FROM sop_versions sv2 WHERE sv2.sop_id = sv.sop_id AND sv2.status = 'published')").Error; err != nil { return fmt.Errorf("remove historical SOP nodes: %w", err) } if err := db.Exec("DELETE e FROM sop_edges e JOIN sop_versions sv ON sv.id = e.sop_version_id WHERE sv.status <> 'published' OR sv.id <> (SELECT MAX(sv2.id) FROM sop_versions sv2 WHERE sv2.sop_id = sv.sop_id AND sv2.status = 'published')").Error; err != nil { return fmt.Errorf("remove historical SOP edges: %w", err) } } if err := removeLegacySOPVersionColumns(db); err != nil { return err } return nil } // removeLegacySOPVersionColumns completes the single-version migration. The // old columns are not represented by the GORM models, so AutoMigrate cannot // remove them. Leaving a NOT NULL sop_version_id behind makes inserts fail. func removeLegacySOPVersionColumns(db *gorm.DB) error { for _, table := range []string{"sop_nodes", "sop_edges", "sop_runs"} { if !db.Migrator().HasTable(table) { continue } if !db.Migrator().HasColumn(table, "sop_id") { continue } var missing int64 if err := db.Table(table).Where("sop_id IS NULL").Count(&missing).Error; err != nil { return fmt.Errorf("check %s SOP references: %w", table, err) } if missing > 0 { return fmt.Errorf("cannot remove %s.sop_version_id: %d rows have no sop_id", table, missing) } if err := db.Exec(fmt.Sprintf("ALTER TABLE %s MODIFY COLUMN sop_id BIGINT UNSIGNED NOT NULL", quoteIdentifier(table))).Error; err != nil { return fmt.Errorf("make %s.sop_id required: %w", table, err) } if !db.Migrator().HasColumn(table, "sop_version_id") { continue } if err := dropIndexesForColumn(db, table, "sop_version_id"); err != nil { return err } if err := db.Exec(fmt.Sprintf("ALTER TABLE %s DROP COLUMN sop_version_id", quoteIdentifier(table))).Error; err != nil { return fmt.Errorf("drop %s.sop_version_id: %w", table, err) } } if db.Migrator().HasTable("sop_versions") { if err := db.Exec("DROP TABLE sop_versions").Error; err != nil { return fmt.Errorf("drop sop_versions: %w", err) } } return nil } func dropIndexesForColumn(db *gorm.DB, table, column string) error { var indexes []struct { Name string `gorm:"column:index_name"` } if err := db.Raw("SELECT DISTINCT INDEX_NAME AS index_name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? AND INDEX_NAME <> 'PRIMARY'", table, column).Scan(&indexes).Error; err != nil { return fmt.Errorf("list %s.%s indexes: %w", table, column, err) } for _, index := range indexes { if index.Name == "" { continue } if err := db.Exec(fmt.Sprintf("ALTER TABLE %s DROP INDEX %s", quoteIdentifier(table), quoteIdentifier(index.Name))).Error; err != nil { return fmt.Errorf("drop index %s.%s: %w", table, index.Name, err) } } return nil } func dropAllForeignKeys(db *gorm.DB) error { var constraints []struct { TableName string `gorm:"column:table_name"` ConstraintName string `gorm:"column:constraint_name"` } if err := db.Raw("SELECT TABLE_NAME AS table_name, CONSTRAINT_NAME AS constraint_name FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_TYPE = 'FOREIGN KEY'").Scan(&constraints).Error; err != nil { return fmt.Errorf("list foreign keys: %w", err) } for _, constraint := range constraints { if constraint.TableName == "" || constraint.ConstraintName == "" { continue } query := fmt.Sprintf("ALTER TABLE %s DROP FOREIGN KEY %s", quoteIdentifier(constraint.TableName), quoteIdentifier(constraint.ConstraintName)) if err := db.Exec(query).Error; err != nil { return fmt.Errorf("drop foreign key %s.%s: %w", constraint.TableName, constraint.ConstraintName, err) } } return nil } func quoteIdentifier(value string) string { return "`" + strings.ReplaceAll(value, "`", "``") + "`" } type zapGORMLogger struct { log *zap.Logger level gormlogger.LogLevel } func newGORMLogger(log *zap.Logger, level gormlogger.LogLevel) gormlogger.Interface { return &zapGORMLogger{log: log, level: level} } func (l *zapGORMLogger) LogMode(level gormlogger.LogLevel) gormlogger.Interface { clone := *l clone.level = level return &clone } func (l *zapGORMLogger) Info(_ context.Context, msg string, data ...interface{}) { if l.level >= gormlogger.Info { l.log.Sugar().Infof(msg, data...) } } func (l *zapGORMLogger) Warn(_ context.Context, msg string, data ...interface{}) { if l.level >= gormlogger.Warn { l.log.Sugar().Warnf(msg, data...) } } func (l *zapGORMLogger) Error(_ context.Context, msg string, data ...interface{}) { if l.level >= gormlogger.Error { l.log.Sugar().Errorf(msg, data...) } } func (l *zapGORMLogger) Trace(_ context.Context, begin time.Time, fc func() (string, int64), err error) { if l.level == gormlogger.Silent { return } sql, rows := fc() fields := []zap.Field{zap.Duration("duration", time.Since(begin)), zap.Int64("rows", rows), zap.String("sql", sql)} if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) && l.level >= gormlogger.Error { l.log.Error("query failed", append(fields, zap.Error(err))...) return } if l.level >= gormlogger.Info { l.log.Debug("query", fields...) } }