Sfoglia il codice sorgente

refactor(db): migrate `Follow` off `user.go` (#7203)

Joe Chen 2 anni fa
parent
commit
b1fefcbe50

+ 14 - 0
docs/dev/database_schema.md

@@ -55,6 +55,20 @@ Indexes:
 	"idx_action_user_id" (user_id)
 ```
 
+# Table "follow"
+
+```
+   FIELD   |  COLUMN   |   POSTGRESQL    |         MYSQL         |     SQLITE3       
+-----------+-----------+-----------------+-----------------------+-------------------
+  ID       | id        | BIGSERIAL       | BIGINT AUTO_INCREMENT | INTEGER           
+  UserID   | user_id   | BIGINT NOT NULL | BIGINT NOT NULL       | INTEGER NOT NULL  
+  FollowID | follow_id | BIGINT NOT NULL | BIGINT NOT NULL       | INTEGER NOT NULL  
+
+Primary keys: id
+Indexes: 
+	"follow_user_follow_unique" UNIQUE (user_id, follow_id)
+```
+
 # Table "lfs_object"
 
 ```

+ 13 - 2
internal/db/backup_test.go

@@ -31,8 +31,8 @@ func TestDumpAndImport(t *testing.T) {
 	}
 	t.Parallel()
 
-	if len(Tables) != 5 {
-		t.Fatalf("New table has added (want 5 got %d), please add new tests for the table and update this check", len(Tables))
+	if len(Tables) != 6 {
+		t.Fatalf("New table has added (want 6 got %d), please add new tests for the table and update this check", len(Tables))
 	}
 
 	db := dbtest.NewDB(t, "dumpAndImport", Tables...)
@@ -131,6 +131,17 @@ func setupDBToDump(t *testing.T, db *gorm.DB) {
 			CreatedUnix:  1588568886,
 		},
 
+		&Follow{
+			ID:       1,
+			UserID:   1,
+			FollowID: 2,
+		},
+		&Follow{
+			ID:       2,
+			UserID:   2,
+			FollowID: 1,
+		},
+
 		&LFSObject{
 			RepoID:    1,
 			OID:       "ef797c8118f02dfb649607dd5d3f8c7623048c9c063d532cc95c5ed7a898a64f",

+ 2 - 0
internal/db/db.go

@@ -42,6 +42,7 @@ func newLogWriter() (logger.Writer, error) {
 // NOTE: Lines are sorted in alphabetical order, each letter in its own line.
 var Tables = []interface{}{
 	new(Access), new(AccessToken), new(Action),
+	new(Follow),
 	new(LFSObject), new(LoginSource),
 }
 
@@ -120,6 +121,7 @@ func Init(w logger.Writer) (*gorm.DB, error) {
 	// Initialize stores, sorted in alphabetical order.
 	AccessTokens = &accessTokens{DB: db}
 	Actions = NewActionsStore(db)
+	Follows = NewFollowsStore(db)
 	LoginSources = &loginSources{DB: db, files: sourceFiles}
 	LFS = &lfs{DB: db}
 	Perms = &perms{DB: db}

+ 127 - 0
internal/db/follows.go

@@ -0,0 +1,127 @@
+// Copyright 2022 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package db
+
+import (
+	"context"
+
+	"github.com/pkg/errors"
+	"gorm.io/gorm"
+)
+
+// FollowsStore is the persistent interface for follows.
+//
+// NOTE: All methods are sorted in alphabetical order.
+type FollowsStore interface {
+	// Follow marks the user to follow the other user.
+	Follow(ctx context.Context, userID, followID int64) error
+	// IsFollowing returns true if the user is following the other user.
+	IsFollowing(ctx context.Context, userID, followID int64) bool
+	// Unfollow removes the mark the user to follow the other user.
+	Unfollow(ctx context.Context, userID, followID int64) error
+}
+
+var Follows FollowsStore
+
+var _ FollowsStore = (*follows)(nil)
+
+type follows struct {
+	*gorm.DB
+}
+
+// NewFollowsStore returns a persistent interface for follows with given
+// database connection.
+func NewFollowsStore(db *gorm.DB) FollowsStore {
+	return &follows{DB: db}
+}
+
+func (*follows) updateFollowingCount(tx *gorm.DB, userID, followID int64) error {
+	/*
+		Equivalent SQL for PostgreSQL:
+
+		UPDATE "user"
+		SET num_followers = (
+			SELECT COUNT(*) FROM follow WHERE follow_id = @followID
+		)
+		WHERE id = @followID
+	*/
+	err := tx.Model(&User{}).
+		Where("id = ?", followID).
+		Update(
+			"num_followers",
+			tx.Model(&Follow{}).Select("COUNT(*)").Where("follow_id = ?", followID),
+		).
+		Error
+	if err != nil {
+		return errors.Wrap(err, `update "num_followers"`)
+	}
+
+	/*
+		Equivalent SQL for PostgreSQL:
+
+		UPDATE "user"
+		SET num_following = (
+			SELECT COUNT(*) FROM follow WHERE user_id = @userID
+		)
+		WHERE id = @userID
+	*/
+	err = tx.Model(&User{}).
+		Where("id = ?", userID).
+		Update(
+			"num_following",
+			tx.Model(&Follow{}).Select("COUNT(*)").Where("user_id = ?", userID),
+		).
+		Error
+	if err != nil {
+		return errors.Wrap(err, `update "num_following"`)
+	}
+	return nil
+}
+
+func (db *follows) Follow(ctx context.Context, userID, followID int64) error {
+	if userID == followID {
+		return nil
+	}
+
+	return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+		f := &Follow{
+			UserID:   userID,
+			FollowID: followID,
+		}
+		result := tx.FirstOrCreate(f, f)
+		if result.Error != nil {
+			return errors.Wrap(result.Error, "upsert")
+		} else if result.RowsAffected <= 0 {
+			return nil // Relation already exists
+		}
+
+		return db.updateFollowingCount(tx, userID, followID)
+	})
+}
+
+func (db *follows) IsFollowing(ctx context.Context, userID, followID int64) bool {
+	return db.WithContext(ctx).Where("user_id = ? AND follow_id = ?", userID, followID).First(&Follow{}).Error == nil
+}
+
+func (db *follows) Unfollow(ctx context.Context, userID, followID int64) error {
+	if userID == followID {
+		return nil
+	}
+
+	return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+		err := tx.Where("user_id = ? AND follow_id = ?", userID, followID).Delete(&Follow{}).Error
+		if err != nil {
+			return errors.Wrap(err, "delete")
+		}
+		return db.updateFollowingCount(tx, userID, followID)
+	})
+}
+
+// Follow represents relations of users and their followers.
+type Follow struct {
+	ID       int64 `gorm:"primaryKey"`
+	UserID   int64 `xorm:"UNIQUE(follow)" gorm:"uniqueIndex:follow_user_follow_unique;not null"`
+	FollowID int64 `xorm:"UNIQUE(follow)" gorm:"uniqueIndex:follow_user_follow_unique;not null"`
+}

+ 122 - 0
internal/db/follows_test.go

@@ -0,0 +1,122 @@
+// Copyright 2022 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package db
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"gogs.io/gogs/internal/dbtest"
+)
+
+func TestFollows(t *testing.T) {
+	if testing.Short() {
+		t.Skip()
+	}
+	t.Parallel()
+
+	tables := []interface{}{new(User), new(EmailAddress), new(Follow)}
+	db := &follows{
+		DB: dbtest.NewDB(t, "follows", tables...),
+	}
+
+	for _, tc := range []struct {
+		name string
+		test func(*testing.T, *follows)
+	}{
+		{"Follow", followsFollow},
+		{"IsFollowing", followsIsFollowing},
+		{"Unfollow", followsUnfollow},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Cleanup(func() {
+				err := clearTables(t, db.DB, tables...)
+				require.NoError(t, err)
+			})
+			tc.test(t, db)
+		})
+		if t.Failed() {
+			break
+		}
+	}
+}
+
+func followsFollow(t *testing.T, db *follows) {
+	ctx := context.Background()
+
+	usersStore := NewUsersStore(db.DB)
+	alice, err := usersStore.Create(ctx, "alice", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+	bob, err := usersStore.Create(ctx, "bob", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+
+	err = db.Follow(ctx, alice.ID, bob.ID)
+	require.NoError(t, err)
+
+	// It is OK to follow multiple times and just be noop.
+	err = db.Follow(ctx, alice.ID, bob.ID)
+	require.NoError(t, err)
+
+	alice, err = usersStore.GetByID(ctx, alice.ID)
+	require.NoError(t, err)
+	assert.Equal(t, 1, alice.NumFollowing)
+
+	bob, err = usersStore.GetByID(ctx, bob.ID)
+	require.NoError(t, err)
+	assert.Equal(t, 1, bob.NumFollowers)
+}
+
+func followsIsFollowing(t *testing.T, db *follows) {
+	ctx := context.Background()
+
+	usersStore := NewUsersStore(db.DB)
+	alice, err := usersStore.Create(ctx, "alice", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+	bob, err := usersStore.Create(ctx, "bob", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+
+	got := db.IsFollowing(ctx, alice.ID, bob.ID)
+	assert.False(t, got)
+
+	err = db.Follow(ctx, alice.ID, bob.ID)
+	require.NoError(t, err)
+	got = db.IsFollowing(ctx, alice.ID, bob.ID)
+	assert.True(t, got)
+
+	err = db.Unfollow(ctx, alice.ID, bob.ID)
+	require.NoError(t, err)
+	got = db.IsFollowing(ctx, alice.ID, bob.ID)
+	assert.False(t, got)
+}
+
+func followsUnfollow(t *testing.T, db *follows) {
+	ctx := context.Background()
+
+	usersStore := NewUsersStore(db.DB)
+	alice, err := usersStore.Create(ctx, "alice", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+	bob, err := usersStore.Create(ctx, "bob", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+
+	err = db.Follow(ctx, alice.ID, bob.ID)
+	require.NoError(t, err)
+
+	// It is OK to unfollow multiple times and just be noop.
+	err = db.Unfollow(ctx, alice.ID, bob.ID)
+	require.NoError(t, err)
+	err = db.Unfollow(ctx, alice.ID, bob.ID)
+	require.NoError(t, err)
+
+	alice, err = usersStore.GetByID(ctx, alice.ID)
+	require.NoError(t, err)
+	assert.Equal(t, 0, alice.NumFollowing)
+
+	bob, err = usersStore.GetByID(ctx, bob.ID)
+	require.NoError(t, err)
+	assert.Equal(t, 0, bob.NumFollowers)
+}

+ 10 - 0
internal/db/main_test.go

@@ -15,6 +15,7 @@ import (
 	_ "modernc.org/sqlite"
 	log "unknwon.dev/clog/v2"
 
+	"gogs.io/gogs/internal/conf"
 	"gogs.io/gogs/internal/testutil"
 )
 
@@ -37,6 +38,15 @@ func TestMain(m *testing.M) {
 	// NOTE: AutoMigrate does not respect logger passed in gorm.Config.
 	logger.Default = logger.Default.LogMode(level)
 
+	switch os.Getenv("GOGS_DATABASE_TYPE") {
+	case "mysql":
+		conf.UseMySQL = true
+	case "postgres":
+		conf.UsePostgreSQL = true
+	default:
+		conf.UseSQLite3 = true
+	}
+
 	os.Exit(m.Run())
 }
 

+ 1 - 1
internal/db/models.go

@@ -52,7 +52,7 @@ func init() {
 	legacyTables = append(legacyTables,
 		new(User), new(PublicKey), new(TwoFactor), new(TwoFactorRecoveryCode),
 		new(Repository), new(DeployKey), new(Collaboration), new(Upload),
-		new(Watch), new(Star), new(Follow),
+		new(Watch), new(Star),
 		new(Issue), new(PullRequest), new(Comment), new(Attachment), new(IssueUser),
 		new(Label), new(IssueLabel), new(Milestone),
 		new(Mirror), new(Release), new(Webhook), new(HookTask),

+ 2 - 0
internal/db/testdata/backup/Follow.golden.json

@@ -0,0 +1,2 @@
+{"ID":1,"UserID":1,"FollowID":2}
+{"ID":2,"UserID":2,"FollowID":1}

+ 0 - 99
internal/db/user.go

@@ -61,34 +61,6 @@ func (u *User) AfterSet(colName string, _ xorm.Cell) {
 	}
 }
 
-// User.GetFollowers returns range of user's followers.
-func (u *User) GetFollowers(page int) ([]*User, error) {
-	users := make([]*User, 0, ItemsPerPage)
-	sess := x.Limit(ItemsPerPage, (page-1)*ItemsPerPage).Where("follow.follow_id=?", u.ID)
-	if conf.UsePostgreSQL {
-		sess = sess.Join("LEFT", "follow", `"user".id=follow.user_id`)
-	} else {
-		sess = sess.Join("LEFT", "follow", "user.id=follow.user_id")
-	}
-	return users, sess.Find(&users)
-}
-
-func (u *User) IsFollowing(followID int64) bool {
-	return IsFollowing(u.ID, followID)
-}
-
-// GetFollowing returns range of user's following.
-func (u *User) GetFollowing(page int) ([]*User, error) {
-	users := make([]*User, 0, ItemsPerPage)
-	sess := x.Limit(ItemsPerPage, (page-1)*ItemsPerPage).Where("follow.user_id=?", u.ID)
-	if conf.UsePostgreSQL {
-		sess = sess.Join("LEFT", "follow", `"user".id=follow.follow_id`)
-	} else {
-		sess = sess.Join("LEFT", "follow", "user.id=follow.follow_id")
-	}
-	return users, sess.Find(&users)
-}
-
 // NewGitSig generates and returns the signature of given user.
 func (u *User) NewGitSig() *git.Signature {
 	return &git.Signature{
@@ -887,77 +859,6 @@ func SearchUserByName(opts *SearchUserOptions) (users []*User, _ int64, _ error)
 	return users, count, sess.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize).Find(&users)
 }
 
-// ___________    .__  .__
-// \_   _____/___ |  | |  |   ______  _  __
-//  |    __)/  _ \|  | |  |  /  _ \ \/ \/ /
-//  |     \(  <_> )  |_|  |_(  <_> )     /
-//  \___  / \____/|____/____/\____/ \/\_/
-//      \/
-
-// Follow represents relations of user and his/her followers.
-type Follow struct {
-	ID       int64
-	UserID   int64 `xorm:"UNIQUE(follow)"`
-	FollowID int64 `xorm:"UNIQUE(follow)"`
-}
-
-func IsFollowing(userID, followID int64) bool {
-	has, _ := x.Get(&Follow{UserID: userID, FollowID: followID})
-	return has
-}
-
-// FollowUser marks someone be another's follower.
-func FollowUser(userID, followID int64) (err error) {
-	if userID == followID || IsFollowing(userID, followID) {
-		return nil
-	}
-
-	sess := x.NewSession()
-	defer sess.Close()
-	if err = sess.Begin(); err != nil {
-		return err
-	}
-
-	if _, err = sess.Insert(&Follow{UserID: userID, FollowID: followID}); err != nil {
-		return err
-	}
-
-	if _, err = sess.Exec("UPDATE `user` SET num_followers = num_followers + 1 WHERE id = ?", followID); err != nil {
-		return err
-	}
-
-	if _, err = sess.Exec("UPDATE `user` SET num_following = num_following + 1 WHERE id = ?", userID); err != nil {
-		return err
-	}
-	return sess.Commit()
-}
-
-// UnfollowUser unmarks someone be another's follower.
-func UnfollowUser(userID, followID int64) (err error) {
-	if userID == followID || !IsFollowing(userID, followID) {
-		return nil
-	}
-
-	sess := x.NewSession()
-	defer sess.Close()
-	if err = sess.Begin(); err != nil {
-		return err
-	}
-
-	if _, err = sess.Delete(&Follow{UserID: userID, FollowID: followID}); err != nil {
-		return err
-	}
-
-	if _, err = sess.Exec("UPDATE `user` SET num_followers = num_followers - 1 WHERE id = ?", followID); err != nil {
-		return err
-	}
-
-	if _, err = sess.Exec("UPDATE `user` SET num_following = num_following - 1 WHERE id = ?", userID); err != nil {
-		return err
-	}
-	return sess.Commit()
-}
-
 // GetRepositoryAccesses finds all repositories with their access mode where a user has access but does not own.
 func (u *User) GetRepositoryAccesses() (map[*Repository]AccessMode, error) {
 	accesses := make([]*Access, 0, 10)

+ 62 - 0
internal/db/users.go

@@ -56,6 +56,14 @@ type UsersStore interface {
 	GetByUsername(ctx context.Context, username string) (*User, error)
 	// HasForkedRepository returns true if the user has forked given repository.
 	HasForkedRepository(ctx context.Context, userID, repoID int64) bool
+	// ListFollowers returns a list of users that are following the given user.
+	// Results are paginated by given page and page size, and sorted by the time of
+	// follow in descending order.
+	ListFollowers(ctx context.Context, userID int64, page, pageSize int) ([]*User, error)
+	// ListFollowings returns a list of users that are followed by the given user.
+	// Results are paginated by given page and page size, and sorted by the time of
+	// follow in descending order.
+	ListFollowings(ctx context.Context, userID int64, page, pageSize int) ([]*User, error)
 }
 
 var Users UsersStore
@@ -343,6 +351,52 @@ func (db *users) HasForkedRepository(ctx context.Context, userID, repoID int64)
 	return count > 0
 }
 
+func (db *users) ListFollowers(ctx context.Context, userID int64, page, pageSize int) ([]*User, error) {
+	/*
+		Equivalent SQL for PostgreSQL:
+
+		SELECT * FROM "user"
+		LEFT JOIN follow ON follow.user_id = "user".id
+		WHERE follow.follow_id = @userID
+		ORDER BY follow.id DESC
+		LIMIT @limit OFFSET @offset
+	*/
+	users := make([]*User, 0, pageSize)
+	tx := db.WithContext(ctx).
+		Where("follow.follow_id = ?", userID).
+		Limit(pageSize).Offset((page - 1) * pageSize).
+		Order("follow.id DESC")
+	if conf.UsePostgreSQL {
+		tx.Joins(`LEFT JOIN follow ON follow.user_id = "user".id`)
+	} else {
+		tx.Joins(`LEFT JOIN follow ON follow.user_id = user.id`)
+	}
+	return users, tx.Find(&users).Error
+}
+
+func (db *users) ListFollowings(ctx context.Context, userID int64, page, pageSize int) ([]*User, error) {
+	/*
+		Equivalent SQL for PostgreSQL:
+
+		SELECT * FROM "user"
+		LEFT JOIN follow ON follow.user_id = "user".id
+		WHERE follow.user_id = @userID
+		ORDER BY follow.id DESC
+		LIMIT @limit OFFSET @offset
+	*/
+	users := make([]*User, 0, pageSize)
+	tx := db.WithContext(ctx).
+		Where("follow.user_id = ?", userID).
+		Limit(pageSize).Offset((page - 1) * pageSize).
+		Order("follow.id DESC")
+	if conf.UsePostgreSQL {
+		tx.Joins(`LEFT JOIN follow ON follow.follow_id = "user".id`)
+	} else {
+		tx.Joins(`LEFT JOIN follow ON follow.follow_id = user.id`)
+	}
+	return users, tx.Find(&users).Error
+}
+
 // UserType indicates the type of the user account.
 type UserType int
 
@@ -530,3 +584,11 @@ func (u *User) AvatarURL() string {
 	}
 	return link
 }
+
+// IsFollowing returns true if the user is following the given user.
+//
+// TODO(unknwon): This is also used in templates, which should be fixed by
+// having a dedicated type `template.User`.
+func (u *User) IsFollowing(followID int64) bool {
+	return Follows.IsFollowing(context.TODO(), u.ID, followID)
+}

+ 71 - 1
internal/db/users_test.go

@@ -24,7 +24,7 @@ func TestUsers(t *testing.T) {
 	}
 	t.Parallel()
 
-	tables := []interface{}{new(User), new(EmailAddress), new(Repository)}
+	tables := []interface{}{new(User), new(EmailAddress), new(Repository), new(Follow)}
 	db := &users{
 		DB: dbtest.NewDB(t, "users", tables...),
 	}
@@ -39,6 +39,8 @@ func TestUsers(t *testing.T) {
 		{"GetByID", usersGetByID},
 		{"GetByUsername", usersGetByUsername},
 		{"HasForkedRepository", usersHasForkedRepository},
+		{"ListFollowers", usersListFollowers},
+		{"ListFollowings", usersListFollowings},
 	} {
 		t.Run(tc.name, func(t *testing.T) {
 			t.Cleanup(func() {
@@ -296,3 +298,71 @@ func usersHasForkedRepository(t *testing.T, db *users) {
 	has = db.HasForkedRepository(ctx, 1, 1)
 	assert.True(t, has)
 }
+
+func usersListFollowers(t *testing.T, db *users) {
+	ctx := context.Background()
+
+	john, err := db.Create(ctx, "john", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+
+	got, err := db.ListFollowers(ctx, john.ID, 1, 1)
+	require.NoError(t, err)
+	assert.Empty(t, got)
+
+	alice, err := db.Create(ctx, "alice", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+	bob, err := db.Create(ctx, "bob", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+
+	followsStore := NewFollowsStore(db.DB)
+	err = followsStore.Follow(ctx, alice.ID, john.ID)
+	require.NoError(t, err)
+	err = followsStore.Follow(ctx, bob.ID, john.ID)
+	require.NoError(t, err)
+
+	// First page only has bob
+	got, err = db.ListFollowers(ctx, john.ID, 1, 1)
+	require.NoError(t, err)
+	require.Len(t, got, 1)
+	assert.Equal(t, bob.ID, got[0].ID)
+
+	// Second page only has alice
+	got, err = db.ListFollowers(ctx, john.ID, 2, 1)
+	require.NoError(t, err)
+	require.Len(t, got, 1)
+	assert.Equal(t, alice.ID, got[0].ID)
+}
+
+func usersListFollowings(t *testing.T, db *users) {
+	ctx := context.Background()
+
+	john, err := db.Create(ctx, "john", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+
+	got, err := db.ListFollowers(ctx, john.ID, 1, 1)
+	require.NoError(t, err)
+	assert.Empty(t, got)
+
+	alice, err := db.Create(ctx, "alice", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+	bob, err := db.Create(ctx, "bob", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+
+	followsStore := NewFollowsStore(db.DB)
+	err = followsStore.Follow(ctx, john.ID, alice.ID)
+	require.NoError(t, err)
+	err = followsStore.Follow(ctx, john.ID, bob.ID)
+	require.NoError(t, err)
+
+	// First page only has bob
+	got, err = db.ListFollowings(ctx, john.ID, 1, 1)
+	require.NoError(t, err)
+	require.Len(t, got, 1)
+	assert.Equal(t, bob.ID, got[0].ID)
+
+	// Second page only has alice
+	got, err = db.ListFollowings(ctx, john.ID, 2, 1)
+	require.NoError(t, err)
+	require.Len(t, got, 1)
+	assert.Equal(t, alice.ID, got[0].ID)
+}

+ 7 - 7
internal/route/api/v1/user/follower.go

@@ -20,9 +20,9 @@ func responseApiUsers(c *context.APIContext, users []*db.User) {
 }
 
 func listUserFollowers(c *context.APIContext, u *db.User) {
-	users, err := u.GetFollowers(c.QueryInt("page"))
+	users, err := db.Users.ListFollowers(c.Req.Context(), u.ID, c.QueryInt("page"), db.ItemsPerPage)
 	if err != nil {
-		c.Error(err, "get followers")
+		c.Error(err, "list followers")
 		return
 	}
 	responseApiUsers(c, users)
@@ -41,9 +41,9 @@ func ListFollowers(c *context.APIContext) {
 }
 
 func listUserFollowing(c *context.APIContext, u *db.User) {
-	users, err := u.GetFollowing(c.QueryInt("page"))
+	users, err := db.Users.ListFollowings(c.Req.Context(), u.ID, c.QueryInt("page"), db.ItemsPerPage)
 	if err != nil {
-		c.Error(err, "get following")
+		c.Error(err, "list followings")
 		return
 	}
 	responseApiUsers(c, users)
@@ -62,7 +62,7 @@ func ListFollowing(c *context.APIContext) {
 }
 
 func checkUserFollowing(c *context.APIContext, u *db.User, followID int64) {
-	if u.IsFollowing(followID) {
+	if db.Follows.IsFollowing(c.Req.Context(), u.ID, followID) {
 		c.NoContent()
 	} else {
 		c.NotFound()
@@ -94,7 +94,7 @@ func Follow(c *context.APIContext) {
 	if c.Written() {
 		return
 	}
-	if err := db.FollowUser(c.User.ID, target.ID); err != nil {
+	if err := db.Follows.Follow(c.Req.Context(), c.User.ID, target.ID); err != nil {
 		c.Error(err, "follow user")
 		return
 	}
@@ -106,7 +106,7 @@ func Unfollow(c *context.APIContext) {
 	if c.Written() {
 		return
 	}
-	if err := db.UnfollowUser(c.User.ID, target.ID); err != nil {
+	if err := db.Follows.Unfollow(c.Req.Context(), c.User.ID, target.ID); err != nil {
 		c.Error(err, "unfollow user")
 		return
 	}

+ 260 - 0
internal/route/lfs/mocks_test.go

@@ -2311,6 +2311,12 @@ type MockUsersStore struct {
 	// HasForkedRepositoryFunc is an instance of a mock function object
 	// controlling the behavior of the method HasForkedRepository.
 	HasForkedRepositoryFunc *UsersStoreHasForkedRepositoryFunc
+	// ListFollowersFunc is an instance of a mock function object
+	// controlling the behavior of the method ListFollowers.
+	ListFollowersFunc *UsersStoreListFollowersFunc
+	// ListFollowingsFunc is an instance of a mock function object
+	// controlling the behavior of the method ListFollowings.
+	ListFollowingsFunc *UsersStoreListFollowingsFunc
 }
 
 // NewMockUsersStore creates a new mock of the UsersStore interface. All
@@ -2347,6 +2353,16 @@ func NewMockUsersStore() *MockUsersStore {
 				return
 			},
 		},
+		ListFollowersFunc: &UsersStoreListFollowersFunc{
+			defaultHook: func(context.Context, int64, int, int) (r0 []*db.User, r1 error) {
+				return
+			},
+		},
+		ListFollowingsFunc: &UsersStoreListFollowingsFunc{
+			defaultHook: func(context.Context, int64, int, int) (r0 []*db.User, r1 error) {
+				return
+			},
+		},
 	}
 }
 
@@ -2384,6 +2400,16 @@ func NewStrictMockUsersStore() *MockUsersStore {
 				panic("unexpected invocation of MockUsersStore.HasForkedRepository")
 			},
 		},
+		ListFollowersFunc: &UsersStoreListFollowersFunc{
+			defaultHook: func(context.Context, int64, int, int) ([]*db.User, error) {
+				panic("unexpected invocation of MockUsersStore.ListFollowers")
+			},
+		},
+		ListFollowingsFunc: &UsersStoreListFollowingsFunc{
+			defaultHook: func(context.Context, int64, int, int) ([]*db.User, error) {
+				panic("unexpected invocation of MockUsersStore.ListFollowings")
+			},
+		},
 	}
 }
 
@@ -2409,6 +2435,12 @@ func NewMockUsersStoreFrom(i db.UsersStore) *MockUsersStore {
 		HasForkedRepositoryFunc: &UsersStoreHasForkedRepositoryFunc{
 			defaultHook: i.HasForkedRepository,
 		},
+		ListFollowersFunc: &UsersStoreListFollowersFunc{
+			defaultHook: i.ListFollowers,
+		},
+		ListFollowingsFunc: &UsersStoreListFollowingsFunc{
+			defaultHook: i.ListFollowings,
+		},
 	}
 }
 
@@ -3072,3 +3104,231 @@ func (c UsersStoreHasForkedRepositoryFuncCall) Args() []interface{} {
 func (c UsersStoreHasForkedRepositoryFuncCall) Results() []interface{} {
 	return []interface{}{c.Result0}
 }
+
+// UsersStoreListFollowersFunc describes the behavior when the ListFollowers
+// method of the parent MockUsersStore instance is invoked.
+type UsersStoreListFollowersFunc struct {
+	defaultHook func(context.Context, int64, int, int) ([]*db.User, error)
+	hooks       []func(context.Context, int64, int, int) ([]*db.User, error)
+	history     []UsersStoreListFollowersFuncCall
+	mutex       sync.Mutex
+}
+
+// ListFollowers delegates to the next hook function in the queue and stores
+// the parameter and result values of this invocation.
+func (m *MockUsersStore) ListFollowers(v0 context.Context, v1 int64, v2 int, v3 int) ([]*db.User, error) {
+	r0, r1 := m.ListFollowersFunc.nextHook()(v0, v1, v2, v3)
+	m.ListFollowersFunc.appendCall(UsersStoreListFollowersFuncCall{v0, v1, v2, v3, r0, r1})
+	return r0, r1
+}
+
+// SetDefaultHook sets function that is called when the ListFollowers method
+// of the parent MockUsersStore instance is invoked and the hook queue is
+// empty.
+func (f *UsersStoreListFollowersFunc) SetDefaultHook(hook func(context.Context, int64, int, int) ([]*db.User, error)) {
+	f.defaultHook = hook
+}
+
+// PushHook adds a function to the end of hook queue. Each invocation of the
+// ListFollowers method of the parent MockUsersStore instance invokes the
+// hook at the front of the queue and discards it. After the queue is empty,
+// the default hook function is invoked for any future action.
+func (f *UsersStoreListFollowersFunc) PushHook(hook func(context.Context, int64, int, int) ([]*db.User, error)) {
+	f.mutex.Lock()
+	f.hooks = append(f.hooks, hook)
+	f.mutex.Unlock()
+}
+
+// SetDefaultReturn calls SetDefaultHook with a function that returns the
+// given values.
+func (f *UsersStoreListFollowersFunc) SetDefaultReturn(r0 []*db.User, r1 error) {
+	f.SetDefaultHook(func(context.Context, int64, int, int) ([]*db.User, error) {
+		return r0, r1
+	})
+}
+
+// PushReturn calls PushHook with a function that returns the given values.
+func (f *UsersStoreListFollowersFunc) PushReturn(r0 []*db.User, r1 error) {
+	f.PushHook(func(context.Context, int64, int, int) ([]*db.User, error) {
+		return r0, r1
+	})
+}
+
+func (f *UsersStoreListFollowersFunc) nextHook() func(context.Context, int64, int, int) ([]*db.User, error) {
+	f.mutex.Lock()
+	defer f.mutex.Unlock()
+
+	if len(f.hooks) == 0 {
+		return f.defaultHook
+	}
+
+	hook := f.hooks[0]
+	f.hooks = f.hooks[1:]
+	return hook
+}
+
+func (f *UsersStoreListFollowersFunc) appendCall(r0 UsersStoreListFollowersFuncCall) {
+	f.mutex.Lock()
+	f.history = append(f.history, r0)
+	f.mutex.Unlock()
+}
+
+// History returns a sequence of UsersStoreListFollowersFuncCall objects
+// describing the invocations of this function.
+func (f *UsersStoreListFollowersFunc) History() []UsersStoreListFollowersFuncCall {
+	f.mutex.Lock()
+	history := make([]UsersStoreListFollowersFuncCall, len(f.history))
+	copy(history, f.history)
+	f.mutex.Unlock()
+
+	return history
+}
+
+// UsersStoreListFollowersFuncCall is an object that describes an invocation
+// of method ListFollowers on an instance of MockUsersStore.
+type UsersStoreListFollowersFuncCall struct {
+	// Arg0 is the value of the 1st argument passed to this method
+	// invocation.
+	Arg0 context.Context
+	// Arg1 is the value of the 2nd argument passed to this method
+	// invocation.
+	Arg1 int64
+	// Arg2 is the value of the 3rd argument passed to this method
+	// invocation.
+	Arg2 int
+	// Arg3 is the value of the 4th argument passed to this method
+	// invocation.
+	Arg3 int
+	// Result0 is the value of the 1st result returned from this method
+	// invocation.
+	Result0 []*db.User
+	// Result1 is the value of the 2nd result returned from this method
+	// invocation.
+	Result1 error
+}
+
+// Args returns an interface slice containing the arguments of this
+// invocation.
+func (c UsersStoreListFollowersFuncCall) Args() []interface{} {
+	return []interface{}{c.Arg0, c.Arg1, c.Arg2, c.Arg3}
+}
+
+// Results returns an interface slice containing the results of this
+// invocation.
+func (c UsersStoreListFollowersFuncCall) Results() []interface{} {
+	return []interface{}{c.Result0, c.Result1}
+}
+
+// UsersStoreListFollowingsFunc describes the behavior when the
+// ListFollowings method of the parent MockUsersStore instance is invoked.
+type UsersStoreListFollowingsFunc struct {
+	defaultHook func(context.Context, int64, int, int) ([]*db.User, error)
+	hooks       []func(context.Context, int64, int, int) ([]*db.User, error)
+	history     []UsersStoreListFollowingsFuncCall
+	mutex       sync.Mutex
+}
+
+// ListFollowings delegates to the next hook function in the queue and
+// stores the parameter and result values of this invocation.
+func (m *MockUsersStore) ListFollowings(v0 context.Context, v1 int64, v2 int, v3 int) ([]*db.User, error) {
+	r0, r1 := m.ListFollowingsFunc.nextHook()(v0, v1, v2, v3)
+	m.ListFollowingsFunc.appendCall(UsersStoreListFollowingsFuncCall{v0, v1, v2, v3, r0, r1})
+	return r0, r1
+}
+
+// SetDefaultHook sets function that is called when the ListFollowings
+// method of the parent MockUsersStore instance is invoked and the hook
+// queue is empty.
+func (f *UsersStoreListFollowingsFunc) SetDefaultHook(hook func(context.Context, int64, int, int) ([]*db.User, error)) {
+	f.defaultHook = hook
+}
+
+// PushHook adds a function to the end of hook queue. Each invocation of the
+// ListFollowings method of the parent MockUsersStore instance invokes the
+// hook at the front of the queue and discards it. After the queue is empty,
+// the default hook function is invoked for any future action.
+func (f *UsersStoreListFollowingsFunc) PushHook(hook func(context.Context, int64, int, int) ([]*db.User, error)) {
+	f.mutex.Lock()
+	f.hooks = append(f.hooks, hook)
+	f.mutex.Unlock()
+}
+
+// SetDefaultReturn calls SetDefaultHook with a function that returns the
+// given values.
+func (f *UsersStoreListFollowingsFunc) SetDefaultReturn(r0 []*db.User, r1 error) {
+	f.SetDefaultHook(func(context.Context, int64, int, int) ([]*db.User, error) {
+		return r0, r1
+	})
+}
+
+// PushReturn calls PushHook with a function that returns the given values.
+func (f *UsersStoreListFollowingsFunc) PushReturn(r0 []*db.User, r1 error) {
+	f.PushHook(func(context.Context, int64, int, int) ([]*db.User, error) {
+		return r0, r1
+	})
+}
+
+func (f *UsersStoreListFollowingsFunc) nextHook() func(context.Context, int64, int, int) ([]*db.User, error) {
+	f.mutex.Lock()
+	defer f.mutex.Unlock()
+
+	if len(f.hooks) == 0 {
+		return f.defaultHook
+	}
+
+	hook := f.hooks[0]
+	f.hooks = f.hooks[1:]
+	return hook
+}
+
+func (f *UsersStoreListFollowingsFunc) appendCall(r0 UsersStoreListFollowingsFuncCall) {
+	f.mutex.Lock()
+	f.history = append(f.history, r0)
+	f.mutex.Unlock()
+}
+
+// History returns a sequence of UsersStoreListFollowingsFuncCall objects
+// describing the invocations of this function.
+func (f *UsersStoreListFollowingsFunc) History() []UsersStoreListFollowingsFuncCall {
+	f.mutex.Lock()
+	history := make([]UsersStoreListFollowingsFuncCall, len(f.history))
+	copy(history, f.history)
+	f.mutex.Unlock()
+
+	return history
+}
+
+// UsersStoreListFollowingsFuncCall is an object that describes an
+// invocation of method ListFollowings on an instance of MockUsersStore.
+type UsersStoreListFollowingsFuncCall struct {
+	// Arg0 is the value of the 1st argument passed to this method
+	// invocation.
+	Arg0 context.Context
+	// Arg1 is the value of the 2nd argument passed to this method
+	// invocation.
+	Arg1 int64
+	// Arg2 is the value of the 3rd argument passed to this method
+	// invocation.
+	Arg2 int
+	// Arg3 is the value of the 4th argument passed to this method
+	// invocation.
+	Arg3 int
+	// Result0 is the value of the 1st result returned from this method
+	// invocation.
+	Result0 []*db.User
+	// Result1 is the value of the 2nd result returned from this method
+	// invocation.
+	Result1 error
+}
+
+// Args returns an interface slice containing the arguments of this
+// invocation.
+func (c UsersStoreListFollowingsFuncCall) Args() []interface{} {
+	return []interface{}{c.Arg0, c.Arg1, c.Arg2, c.Arg3}
+}
+
+// Results returns an interface slice containing the results of this
+// invocation.
+func (c UsersStoreListFollowingsFuncCall) Results() []interface{} {
+	return []interface{}{c.Result0, c.Result1}
+}

+ 18 - 4
internal/route/user/profile.go

@@ -88,7 +88,14 @@ func Followers(c *context.Context, puser *context.ParamsUser) {
 	c.PageIs("Followers")
 	c.Data["CardsTitle"] = c.Tr("user.followers")
 	c.Data["Owner"] = puser
-	repo.RenderUserCards(c, puser.NumFollowers, puser.GetFollowers, FOLLOWERS)
+	repo.RenderUserCards(
+		c,
+		puser.NumFollowers,
+		func(page int) ([]*db.User, error) {
+			return db.Users.ListFollowers(c.Req.Context(), puser.ID, page, db.ItemsPerPage)
+		},
+		FOLLOWERS,
+	)
 }
 
 func Following(c *context.Context, puser *context.ParamsUser) {
@@ -96,7 +103,14 @@ func Following(c *context.Context, puser *context.ParamsUser) {
 	c.PageIs("Following")
 	c.Data["CardsTitle"] = c.Tr("user.following")
 	c.Data["Owner"] = puser
-	repo.RenderUserCards(c, puser.NumFollowing, puser.GetFollowing, FOLLOWERS)
+	repo.RenderUserCards(
+		c,
+		puser.NumFollowing,
+		func(page int) ([]*db.User, error) {
+			return db.Users.ListFollowings(c.Req.Context(), puser.ID, page, db.ItemsPerPage)
+		},
+		FOLLOWERS,
+	)
 }
 
 func Stars(_ *context.Context) {
@@ -106,9 +120,9 @@ func Action(c *context.Context, puser *context.ParamsUser) {
 	var err error
 	switch c.Params(":action") {
 	case "follow":
-		err = db.FollowUser(c.UserID(), puser.ID)
+		err = db.Follows.Follow(c.Req.Context(), c.UserID(), puser.ID)
 	case "unfollow":
-		err = db.UnfollowUser(c.UserID(), puser.ID)
+		err = db.Follows.Unfollow(c.Req.Context(), c.UserID(), puser.ID)
 	}
 
 	if err != nil {