Skip to content

Commit

Permalink
refactor(spx-backend): implement soft unique index via generated colu…
Browse files Browse the repository at this point in the history
…mn (#1055)

Replace trigger-based implementation in #1024 with MySQL generated
column.

Signed-off-by: Aofei Sheng <[email protected]>
  • Loading branch information
aofei authored Oct 30, 2024
1 parent 16efb43 commit ea9e957
Show file tree
Hide file tree
Showing 4 changed files with 27 additions and 131 deletions.
4 changes: 1 addition & 3 deletions spx-backend/go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand Down
18 changes: 10 additions & 8 deletions spx-backend/internal/model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand All @@ -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
}
Expand Down
62 changes: 6 additions & 56 deletions spx-backend/internal/model/project.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"`
Expand Down Expand Up @@ -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
}
74 changes: 10 additions & 64 deletions spx-backend/internal/model/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,19 @@ 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.
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"`
Expand All @@ -40,71 +39,16 @@ 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].
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
Expand All @@ -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.
Expand Down

0 comments on commit ea9e957

Please sign in to comment.