diff --git a/models/fixtures/badge.yml b/models/fixtures/badge.yml new file mode 100644 index 0000000000000..438cd0ca5d4fe --- /dev/null +++ b/models/fixtures/badge.yml @@ -0,0 +1,5 @@ +- + id: 1 + slug: badge1 + description: just a test badge + image_url: badge1.png diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 572738013f802..0aaffd419872d 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -378,6 +378,7 @@ func prepareMigrationTasks() []*migration { newMigration(315, "Add Ephemeral to ActionRunner", v1_24.AddEphemeralToActionRunner), newMigration(316, "Add description for secrets and variables", v1_24.AddDescriptionForSecretsAndVariables), newMigration(317, "Add new index for action for heatmap", v1_24.AddNewIndexForUserDashboard), + newMigration(318, "Add unique index for user_badge table", v1_24.AddUniqueIndexForUserBadge), } return preparedMigrations } diff --git a/models/migrations/v1_24/v318.go b/models/migrations/v1_24/v318.go new file mode 100644 index 0000000000000..2a9fd8d171950 --- /dev/null +++ b/models/migrations/v1_24/v318.go @@ -0,0 +1,62 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_24 //nolint + +import ( + "fmt" + + "xorm.io/xorm" + "xorm.io/xorm/schemas" +) + +type UserBadge struct { //revive:disable-line:exported + ID int64 `xorm:"pk autoincr"` + BadgeID int64 + UserID int64 +} + +// TableIndices implements xorm's TableIndices interface +func (n *UserBadge) TableIndices() []*schemas.Index { + indices := make([]*schemas.Index, 0, 1) + ubUnique := schemas.NewIndex("unique_user_badge", schemas.UniqueType) + ubUnique.AddColumn("user_id", "badge_id") + indices = append(indices, ubUnique) + return indices +} + +// AddUniqueIndexForUserBadge adds a compound unique indexes for user badge table +// and it replaces an old index on user_id +func AddUniqueIndexForUserBadge(x *xorm.Engine) error { + // remove possible duplicated records in table user_badge + type result struct { + UserID int64 + BadgeID int64 + Cnt int + } + var results []result + if err := x.Select("user_id, badge_id, count(*) as cnt"). + Table("user_badge"). + GroupBy("user_id, badge_id"). + Having("count(*) > 1"). + Find(&results); err != nil { + return err + } + for _, r := range results { + if x.Dialect().URI().DBType == schemas.MSSQL { + if _, err := x.Exec(fmt.Sprintf("delete from user_badge where id in (SELECT top %d id FROM user_badge WHERE user_id = ? and badge_id = ?)", r.Cnt-1), r.UserID, r.BadgeID); err != nil { + return err + } + } else { + var ids []int64 + if err := x.SQL("SELECT id FROM user_badge WHERE user_id = ? and badge_id = ? limit ?", r.UserID, r.BadgeID, r.Cnt-1).Find(&ids); err != nil { + return err + } + if _, err := x.Table("user_badge").In("id", ids).Delete(); err != nil { + return err + } + } + } + + return x.Sync(new(UserBadge)) +} diff --git a/models/user/badge.go b/models/user/badge.go index 3ff3530a369a5..e40e27597618c 100644 --- a/models/user/badge.go +++ b/models/user/badge.go @@ -8,6 +8,8 @@ import ( "fmt" "code.gitea.io/gitea/models/db" + + "xorm.io/xorm/schemas" ) // Badge represents a user badge @@ -22,7 +24,16 @@ type Badge struct { type UserBadge struct { //nolint:revive ID int64 `xorm:"pk autoincr"` BadgeID int64 - UserID int64 `xorm:"INDEX"` + UserID int64 +} + +// TableIndices implements xorm's TableIndices interface +func (n *UserBadge) TableIndices() []*schemas.Index { + indices := make([]*schemas.Index, 0, 1) + ubUnique := schemas.NewIndex("unique_user_badge", schemas.UniqueType) + ubUnique.AddColumn("user_id", "badge_id") + indices = append(indices, ubUnique) + return indices } func init() { @@ -105,13 +116,23 @@ func RemoveUserBadge(ctx context.Context, u *User, badge *Badge) error { // RemoveUserBadges removes badges from a user. func RemoveUserBadges(ctx context.Context, u *User, badges []*Badge) error { return db.WithTx(ctx, func(ctx context.Context) error { + badgeSlugs := make([]string, 0, len(badges)) for _, badge := range badges { - if _, err := db.GetEngine(ctx). - Join("INNER", "badge", "badge.id = `user_badge`.badge_id"). - Where("`user_badge`.user_id=? AND `badge`.slug=?", u.ID, badge.Slug). - Delete(&UserBadge{}); err != nil { - return err - } + badgeSlugs = append(badgeSlugs, badge.Slug) + } + var userBadges []UserBadge + if err := db.GetEngine(ctx).Table("user_badge"). + Join("INNER", "badge", "badge.id = `user_badge`.badge_id"). + Where("`user_badge`.user_id = ?", u.ID).In("`badge`.slug", badgeSlugs). + Find(&userBadges); err != nil { + return err + } + userBadgeIDs := make([]int64, 0, len(userBadges)) + for _, ub := range userBadges { + userBadgeIDs = append(userBadgeIDs, ub.ID) + } + if _, err := db.GetEngine(ctx).Table("user_badge").In("id", userBadgeIDs).Delete(); err != nil { + return err } return nil }) diff --git a/models/user/badge_test.go b/models/user/badge_test.go new file mode 100644 index 0000000000000..fba9ea04f9986 --- /dev/null +++ b/models/user/badge_test.go @@ -0,0 +1,42 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user_test + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" +) + +func TestAddAndRemoveUserBadges(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + badge1 := unittest.AssertExistsAndLoadBean(t, &user_model.Badge{ID: 1}) + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + // Add a badge to user and verify that it is returned in the list + assert.NoError(t, user_model.AddUserBadge(db.DefaultContext, user1, badge1)) + badges, count, err := user_model.GetUserBadges(db.DefaultContext, user1) + assert.Equal(t, int64(1), count) + assert.Equal(t, badge1.Slug, badges[0].Slug) + assert.NoError(t, err) + + // Confirm that it is impossible to duplicate the same badge + assert.Error(t, user_model.AddUserBadge(db.DefaultContext, user1, badge1)) + + // Nothing happened to the existing badge + badges, count, err = user_model.GetUserBadges(db.DefaultContext, user1) + assert.Equal(t, int64(1), count) + assert.Equal(t, badge1.Slug, badges[0].Slug) + assert.NoError(t, err) + + // Remove a badge from user and verify that it is no longer in the list + assert.NoError(t, user_model.RemoveUserBadge(db.DefaultContext, user1, badge1)) + _, count, err = user_model.GetUserBadges(db.DefaultContext, user1) + assert.Equal(t, int64(0), count) + assert.NoError(t, err) +}