Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(spx-backend): implement soft unique index via generated column #1055

Merged
merged 1 commit into from
Oct 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading