diff --git a/spx-backend/go.mod b/spx-backend/go.mod index 4970c923a..3764e2ded 100644 --- a/spx-backend/go.mod +++ b/spx-backend/go.mod @@ -1,8 +1,6 @@ module github.com/goplus/builder/spx-backend -go 1.21 - -toolchain go1.21.3 +go 1.21.0 require ( github.com/go-sql-driver/mysql v1.8.1 diff --git a/spx-backend/internal/model/model.go b/spx-backend/internal/model/model.go index d0d744a77..6891770b1 100644 --- a/spx-backend/internal/model/model.go +++ b/spx-backend/internal/model/model.go @@ -21,6 +21,16 @@ type Model struct { DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index"` } +// _deleted_at_is_null is the Gorm data type representing a generated column +// that has a value of 1 when deleted_at is null and NULL otherwise. It is +// typically used to create composite soft unique indexes with other columns. +type _deleted_at_is_null bool + +// GormDataType implements [gorm.io/gorm/schema.GormDataTypeInterface]. +func (_deleted_at_is_null) GormDataType() string { + return "bit(1) GENERATED ALWAYS AS (CASE WHEN deleted_at IS NULL THEN 1 ELSE NULL END) STORED" +} + // OpenDB opens the database with the given dsn and models to be migrated. func OpenDB(ctx context.Context, dsn string, maxOpenConns, maxIdleConns int, models ...any) (*gorm.DB, error) { dialector := mysql.New(mysql.Config{DSN: dsn}) @@ -41,14 +51,6 @@ func OpenDB(ctx context.Context, dsn string, maxOpenConns, maxIdleConns int, mod if err := db.WithContext(ctx).AutoMigrate(models...); err != nil { return nil, fmt.Errorf("failed to auto migrate models: %w", err) } - for _, model := range models { - // NOTE: Workaround for https://github.com/go-gorm/gorm/issues/7227. - if am, ok := model.(interface{ AfterMigrate(tx *gorm.DB) error }); ok { - if err := db.WithContext(ctx).Transaction(am.AfterMigrate); err != nil { - return nil, fmt.Errorf("failed to run AfterMigrate: %w", err) - } - } - } } return db, nil } diff --git a/spx-backend/internal/model/project.go b/spx-backend/internal/model/project.go index cb4ce5589..828305409 100644 --- a/spx-backend/internal/model/project.go +++ b/spx-backend/internal/model/project.go @@ -1,17 +1,13 @@ package model -import ( - "database/sql" - - "gorm.io/gorm" -) +import "database/sql" // Project is the model for projects. type Project struct { Model // OwnerID is the ID of the project owner. - OwnerID int64 `gorm:"column:owner_id;index;index:,composite:owner_id_name"` + OwnerID int64 `gorm:"column:owner_id;index;index:,composite:owner_id_name,unique"` Owner User `gorm:"foreignKey:OwnerID"` // RemixedFromReleaseID is the ID of the project release from which the @@ -27,7 +23,7 @@ type Project struct { LatestRelease *ProjectRelease `gorm:"foreignKey:LatestReleaseID"` // Name is the unique name. - Name string `gorm:"column:name;index:,class:FULLTEXT;index:,composite:owner_id_name"` + Name string `gorm:"column:name;index:,class:FULLTEXT;index:,composite:owner_id_name,unique"` // Version is the version number. Version int `gorm:"column:version"` @@ -59,58 +55,12 @@ type Project struct { // RemixCount is the number of times the project has been remixed. RemixCount int64 `gorm:"column:remix_count;index"` + + // Migration only fields. Do not use in application code. + MO__deleted_at_is_null _deleted_at_is_null `gorm:"->:false;<-:false;column:_deleted_at_is_null;index:,composite:owner_id_name,unique"` } // TableName implements [gorm.io/gorm/schema.Tabler]. func (Project) TableName() string { return "project" } - -func (Project) AfterMigrate(tx *gorm.DB) error { - for _, sql := range []string{ - "DROP TRIGGER IF EXISTS trg_project_before_insert", - ` -CREATE TRIGGER trg_project_before_insert -BEFORE INSERT ON project -FOR EACH ROW -BEGIN - IF NEW.deleted_at IS NULL - AND EXISTS ( - SELECT id FROM project - WHERE owner_id = NEW.owner_id - AND name = NEW.name - AND deleted_at IS NULL - ) THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = 'Duplicate entry: An active record with same owner_id and name already exists'; - END IF; -END - `, - - "DROP TRIGGER IF EXISTS trg_project_before_update", - ` -CREATE TRIGGER trg_project_before_update -BEFORE UPDATE ON project -FOR EACH ROW -BEGIN - IF (NEW.owner_id <> OLD.owner_id OR NEW.name <> OLD.name) - AND NEW.deleted_at IS NULL - AND EXISTS ( - SELECT id FROM project - WHERE owner_id = NEW.owner_id - AND name = NEW.name - AND deleted_at IS NULL - AND id != NEW.id - ) THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = 'Duplicate entry: An active record with same owner_id and name already exists'; - END IF; -END - `, - } { - if err := tx.Exec(sql).Error; err != nil { - return err - } - } - return nil -} diff --git a/spx-backend/internal/model/user.go b/spx-backend/internal/model/user.go index 5269b330d..4177d0312 100644 --- a/spx-backend/internal/model/user.go +++ b/spx-backend/internal/model/user.go @@ -2,12 +2,11 @@ package model import ( "context" - "errors" "fmt" "github.com/casdoor/casdoor-go-sdk/casdoorsdk" - "github.com/go-sql-driver/mysql" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // User is the model for users. @@ -15,7 +14,7 @@ type User struct { Model // Username is the unique username. - Username string `gorm:"column:username;index"` + Username string `gorm:"column:username;index:,composite:username,unique"` // DisplayName is the display name. DisplayName string `gorm:"column:display_name"` @@ -40,6 +39,9 @@ type User struct { // LikedProjectCount is the number of projects liked by the user. LikedProjectCount int64 `gorm:"column:liked_project_count"` + + // Migration only fields. Do not use in application code. + MO__deleted_at_is_null _deleted_at_is_null `gorm:"->:false;<-:false;column:_deleted_at_is_null;index:,composite:username,unique"` } // TableName implements [gorm.io/gorm/schema.Tabler]. @@ -47,64 +49,6 @@ func (User) TableName() string { return "user" } -const userDuplicateUsernameErrorMessage = "Duplicate entry: An active record with same username already exists" - -func (User) AfterMigrate(tx *gorm.DB) error { - for _, sql := range []string{ - "DROP TRIGGER IF EXISTS trg_user_before_insert", - ` -CREATE TRIGGER trg_user_before_insert -BEFORE INSERT ON user -FOR EACH ROW -BEGIN - IF NEW.deleted_at IS NULL - AND EXISTS ( - SELECT id FROM user - WHERE username = NEW.username - AND deleted_at IS NULL - ) THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '` + userDuplicateUsernameErrorMessage + `'; - END IF; -END - `, - - "DROP TRIGGER IF EXISTS trg_user_before_update", - ` -CREATE TRIGGER trg_user_before_update -BEFORE UPDATE ON user -FOR EACH ROW -BEGIN - IF NEW.username <> OLD.username - AND NEW.deleted_at IS NULL - AND EXISTS ( - SELECT id FROM user - WHERE username = NEW.username - AND deleted_at IS NULL - AND id != NEW.id - ) THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '` + userDuplicateUsernameErrorMessage + `'; - END IF; -END - `, - } { - if err := tx.Exec(sql).Error; err != nil { - return err - } - } - return nil -} - -// isUserDuplicateUsernameError reports whether the err is a duplicate username error. -func isUserDuplicateUsernameError(err error) bool { - var mysqlError *mysql.MySQLError - if errors.As(err, &mysqlError) { - return mysqlError.Number == 1213 || (mysqlError.Number == 1644 && mysqlError.Message == userDuplicateUsernameErrorMessage) - } - return false -} - // FirstOrCreateUser gets or creates a user. func FirstOrCreateUser(ctx context.Context, db *gorm.DB, casdoorUser *casdoorsdk.User) (*User, error) { var mUser User @@ -115,11 +59,13 @@ func FirstOrCreateUser(ctx context.Context, db *gorm.DB, casdoorUser *casdoorsdk DisplayName: casdoorUser.DisplayName, Avatar: casdoorUser.Avatar, }). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "username"}}, + DoNothing: true, + }). FirstOrCreate(&mUser). Error; err != nil { - if !isUserDuplicateUsernameError(err) { - return nil, fmt.Errorf("failed to get/create user %s: %w", casdoorUser.Name, err) - } + return nil, fmt.Errorf("failed to get/create user %s: %w", casdoorUser.Name, err) } if mUser.ID == 0 { // Unfortunatlly, MySQL doesn't support the RETURNING clause.