Browse Source

refactor(db): merge relation stores into entity stores (#7341)

Joe Chen 2 years ago
parent
commit
8350daf505

+ 1 - 1
internal/context/repo.go

@@ -403,7 +403,7 @@ func RepoRef() macaron.Handler {
 		c.Data["IsViewCommit"] = c.Repo.IsViewCommit
 
 		// People who have push access or have forked repository can propose a new pull request.
-		if c.Repo.IsWriter() || (c.IsLogged && db.Users.HasForkedRepository(c.Req.Context(), c.User.ID, c.Repo.Repository.ID)) {
+		if c.Repo.IsWriter() || (c.IsLogged && db.Repos.HasForkedBy(c.Req.Context(), c.Repo.Repository.ID, c.User.ID)) {
 			// Pull request is allowed if this is a fork repository
 			// and base repository accepts pull requests.
 			if c.Repo.Repository.BaseRepo != nil {

+ 0 - 2
internal/db/access_tokens.go

@@ -18,8 +18,6 @@ import (
 )
 
 // AccessTokensStore is the persistent interface for access tokens.
-//
-// NOTE: All methods are sorted in alphabetical order.
 type AccessTokensStore interface {
 	// Create creates a new access token and persist to database. It returns
 	// ErrAccessTokenAlreadyExist when an access token with same name already exists

+ 1 - 3
internal/db/actions.go

@@ -29,8 +29,6 @@ import (
 )
 
 // ActionsStore is the persistent interface for actions.
-//
-// NOTE: All methods are sorted in alphabetical order.
 type ActionsStore interface {
 	// CommitRepo creates actions for pushing commits to the repository. An action
 	// with the type ActionDeleteBranch is created if the push deletes a branch; an
@@ -166,7 +164,7 @@ func (db *actions) ListByUser(ctx context.Context, userID, actorID, afterID int6
 
 // notifyWatchers creates rows in action table for watchers who are able to see the action.
 func (db *actions) notifyWatchers(ctx context.Context, act *Action) error {
-	watches, err := NewWatchesStore(db.DB).ListByRepo(ctx, act.RepoID)
+	watches, err := NewReposStore(db.DB).ListWatches(ctx, act.RepoID)
 	if err != nil {
 		return errors.Wrap(err, "list watches")
 	}

+ 0 - 3
internal/db/db.go

@@ -122,16 +122,13 @@ func Init(w logger.Writer) (*gorm.DB, error) {
 	AccessTokens = &accessTokens{DB: db}
 	Actions = NewActionsStore(db)
 	EmailAddresses = NewEmailAddressesStore(db)
-	Follows = NewFollowsStore(db)
 	LoginSources = &loginSources{DB: db, files: sourceFiles}
 	LFS = &lfs{DB: db}
 	Orgs = NewOrgsStore(db)
-	OrgUsers = NewOrgUsersStore(db)
 	Perms = NewPermsStore(db)
 	Repos = NewReposStore(db)
 	TwoFactors = &twoFactors{DB: db}
 	Users = NewUsersStore(db)
-	Watches = NewWatchesStore(db)
 
 	return db, nil
 }

+ 0 - 2
internal/db/email_addresses.go

@@ -15,8 +15,6 @@ import (
 )
 
 // EmailAddressesStore is the persistent interface for email addresses.
-//
-// NOTE: All methods are sorted in alphabetical order.
 type EmailAddressesStore interface {
 	// GetByEmail returns the email address with given email. If `needsActivated` is
 	// true, only activated email will be returned, otherwise, it may return

+ 0 - 127
internal/db/follows.go

@@ -1,127 +0,0 @@
-// 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 user 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 user 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 "user.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 "user.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"`
-}

+ 0 - 122
internal/db/follows_test.go

@@ -1,122 +0,0 @@
-// 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 := []any{new(User), new(EmailAddress), new(Follow)}
-	db := &follows{
-		DB: dbtest.NewDB(t, "follows", tables...),
-	}
-
-	for _, tc := range []struct {
-		name string
-		test func(t *testing.T, db *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)
-}

+ 0 - 2
internal/db/lfs.go

@@ -16,8 +16,6 @@ import (
 )
 
 // LFSStore is the persistent interface for LFS objects.
-//
-// NOTE: All methods are sorted in alphabetical order.
 type LFSStore interface {
 	// CreateObject creates a LFS object record in database.
 	CreateObject(ctx context.Context, repoID int64, oid lfsutil.OID, size int64, storage lfsutil.Storage) error

+ 0 - 2
internal/db/login_source_files.go

@@ -25,8 +25,6 @@ import (
 )
 
 // loginSourceFilesStore is the in-memory interface for login source files stored on file system.
-//
-// NOTE: All methods are sorted in alphabetical order.
 type loginSourceFilesStore interface {
 	// GetByID returns a clone of login source by given ID.
 	GetByID(id int64) (*LoginSource, error)

+ 0 - 2
internal/db/login_sources.go

@@ -23,8 +23,6 @@ import (
 )
 
 // LoginSourcesStore is the persistent interface for login sources.
-//
-// NOTE: All methods are sorted in alphabetical order.
 type LoginSourcesStore interface {
 	// Create creates a new login source and persist to database. It returns
 	// ErrLoginSourceAlreadyExist when a login source with same name already exists.

+ 0 - 38
internal/db/org_users.go

@@ -1,38 +0,0 @@
-// 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"
-
-	"gorm.io/gorm"
-)
-
-// OrgUsersStore is the persistent interface for organization-user relations.
-//
-// NOTE: All methods are sorted in alphabetical order.
-type OrgUsersStore interface {
-	// CountByUser returns the number of organizations the user is a member of.
-	CountByUser(ctx context.Context, userID int64) (int64, error)
-}
-
-var OrgUsers OrgUsersStore
-
-var _ OrgUsersStore = (*orgUsers)(nil)
-
-type orgUsers struct {
-	*gorm.DB
-}
-
-// NewOrgUsersStore returns a persistent interface for organization-user
-// relations with given database connection.
-func NewOrgUsersStore(db *gorm.DB) OrgUsersStore {
-	return &orgUsers{DB: db}
-}
-
-func (db *orgUsers) CountByUser(ctx context.Context, userID int64) (int64, error) {
-	var count int64
-	return count, db.WithContext(ctx).Model(&OrgUser{}).Where("uid = ?", userID).Count(&count).Error
-}

+ 0 - 63
internal/db/org_users_test.go

@@ -1,63 +0,0 @@
-// 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 TestOrgUsers(t *testing.T) {
-	if testing.Short() {
-		t.Skip()
-	}
-	t.Parallel()
-
-	tables := []any{new(OrgUser)}
-	db := &orgUsers{
-		DB: dbtest.NewDB(t, "orgUsers", tables...),
-	}
-
-	for _, tc := range []struct {
-		name string
-		test func(t *testing.T, db *orgUsers)
-	}{
-		{"CountByUser", orgUsersCountByUser},
-	} {
-		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 orgUsersCountByUser(t *testing.T, db *orgUsers) {
-	ctx := context.Background()
-
-	// TODO: Use Orgs.Join to replace SQL hack when the method is available.
-	err := db.Exec(`INSERT INTO org_user (uid, org_id) VALUES (?, ?)`, 1, 1).Error
-	require.NoError(t, err)
-	err = db.Exec(`INSERT INTO org_user (uid, org_id) VALUES (?, ?)`, 2, 1).Error
-	require.NoError(t, err)
-
-	got, err := db.CountByUser(ctx, 1)
-	require.NoError(t, err)
-	assert.Equal(t, int64(1), got)
-
-	got, err = db.CountByUser(ctx, 404)
-	require.NoError(t, err)
-	assert.Equal(t, int64(0), got)
-}

+ 8 - 2
internal/db/orgs.go

@@ -14,8 +14,6 @@ import (
 )
 
 // OrgsStore is the persistent interface for organizations.
-//
-// NOTE: All methods are sorted in alphabetical order.
 type OrgsStore interface {
 	// List returns a list of organizations filtered by options.
 	List(ctx context.Context, opts ListOrgsOptions) ([]*Organization, error)
@@ -25,6 +23,9 @@ type OrgsStore interface {
 	// count of all results is also returned. If the order is not given, it's up to
 	// the database to decide.
 	SearchByName(ctx context.Context, keyword string, page, pageSize int, orderBy string) ([]*Organization, int64, error)
+
+	// CountByUser returns the number of organizations the user is a member of.
+	CountByUser(ctx context.Context, userID int64) (int64, error)
 }
 
 var Orgs OrgsStore
@@ -79,6 +80,11 @@ func (db *orgs) SearchByName(ctx context.Context, keyword string, page, pageSize
 	return searchUserByName(ctx, db.DB, UserTypeOrganization, keyword, page, pageSize, orderBy)
 }
 
+func (db *orgs) CountByUser(ctx context.Context, userID int64) (int64, error) {
+	var count int64
+	return count, db.WithContext(ctx).Model(&OrgUser{}).Where("uid = ?", userID).Count(&count).Error
+}
+
 type Organization = User
 
 func (o *Organization) TableName() string {

+ 19 - 0
internal/db/orgs_test.go

@@ -32,6 +32,7 @@ func TestOrgs(t *testing.T) {
 	}{
 		{"List", orgsList},
 		{"SearchByName", orgsSearchByName},
+		{"CountByUser", orgsCountByUser},
 	} {
 		t.Run(tc.name, func(t *testing.T) {
 			t.Cleanup(func() {
@@ -164,3 +165,21 @@ func orgsSearchByName(t *testing.T, db *orgs) {
 		assert.Equal(t, org2.ID, orgs[0].ID)
 	})
 }
+
+func orgsCountByUser(t *testing.T, db *orgs) {
+	ctx := context.Background()
+
+	// TODO: Use Orgs.Join to replace SQL hack when the method is available.
+	err := db.Exec(`INSERT INTO org_user (uid, org_id) VALUES (?, ?)`, 1, 1).Error
+	require.NoError(t, err)
+	err = db.Exec(`INSERT INTO org_user (uid, org_id) VALUES (?, ?)`, 2, 1).Error
+	require.NoError(t, err)
+
+	got, err := db.CountByUser(ctx, 1)
+	require.NoError(t, err)
+	assert.Equal(t, int64(1), got)
+
+	got, err = db.CountByUser(ctx, 404)
+	require.NoError(t, err)
+	assert.Equal(t, int64(0), got)
+}

+ 0 - 2
internal/db/perms.go

@@ -12,8 +12,6 @@ import (
 )
 
 // PermsStore is the persistent interface for permissions.
-//
-// NOTE: All methods are sorted in alphabetical order.
 type PermsStore interface {
 	// AccessMode returns the access mode of given user has to the repository.
 	AccessMode(ctx context.Context, userID, repoID int64, opts AccessModeOptions) AccessMode

+ 0 - 2
internal/db/public_keys.go

@@ -16,8 +16,6 @@ import (
 )
 
 // PublicKeysStore is the persistent interface for public keys.
-//
-// NOTE: All methods are sorted in alphabetical order.
 type PublicKeysStore interface {
 	// RewriteAuthorizedKeys rewrites the "authorized_keys" file under the SSH root
 	// path with all public keys stored in the database.

+ 56 - 3
internal/db/repos.go

@@ -19,8 +19,6 @@ import (
 )
 
 // ReposStore is the persistent interface for repositories.
-//
-// NOTE: All methods are sorted in alphabetical order.
 type ReposStore interface {
 	// Create creates a new repository record in the database. It returns
 	// ErrNameNotAllowed when the repository name is not allowed, or
@@ -48,6 +46,14 @@ type ReposStore interface {
 	// Touch updates the updated time to the current time and removes the bare state
 	// of the given repository.
 	Touch(ctx context.Context, id int64) error
+
+	// ListWatches returns all watches of the given repository.
+	ListWatches(ctx context.Context, repoID int64) ([]*Watch, error)
+	// Watch marks the user to watch the repository.
+	Watch(ctx context.Context, userID, repoID int64) error
+
+	// HasForkedBy returns true if the given repository has forked by the given user.
+	HasForkedBy(ctx context.Context, repoID, userID int64) bool
 }
 
 var Repos ReposStore
@@ -189,7 +195,7 @@ func (db *repos) Create(ctx context.Context, ownerID int64, opts CreateRepoOptio
 			return errors.Wrap(err, "create")
 		}
 
-		err = NewWatchesStore(tx).Watch(ctx, ownerID, repo.ID)
+		err = NewReposStore(tx).Watch(ctx, ownerID, repo.ID)
 		if err != nil {
 			return errors.Wrap(err, "watch")
 		}
@@ -371,3 +377,50 @@ func (db *repos) Touch(ctx context.Context, id int64) error {
 		}).
 		Error
 }
+
+func (db *repos) ListWatches(ctx context.Context, repoID int64) ([]*Watch, error) {
+	var watches []*Watch
+	return watches, db.WithContext(ctx).Where("repo_id = ?", repoID).Find(&watches).Error
+}
+
+func (db *repos) recountWatches(tx *gorm.DB, repoID int64) error {
+	/*
+		Equivalent SQL for PostgreSQL:
+
+		UPDATE repository
+		SET num_watches = (
+			SELECT COUNT(*) FROM watch WHERE repo_id = @repoID
+		)
+		WHERE id = @repoID
+	*/
+	return tx.Model(&Repository{}).
+		Where("id = ?", repoID).
+		Update(
+			"num_watches",
+			tx.Model(&Watch{}).Select("COUNT(*)").Where("repo_id = ?", repoID),
+		).
+		Error
+}
+
+func (db *repos) Watch(ctx context.Context, userID, repoID int64) error {
+	return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+		w := &Watch{
+			UserID: userID,
+			RepoID: repoID,
+		}
+		result := tx.FirstOrCreate(w, w)
+		if result.Error != nil {
+			return errors.Wrap(result.Error, "upsert")
+		} else if result.RowsAffected <= 0 {
+			return nil // Relation already exists
+		}
+
+		return db.recountWatches(tx, repoID)
+	})
+}
+
+func (db *repos) HasForkedBy(ctx context.Context, repoID, userID int64) bool {
+	var count int64
+	db.WithContext(ctx).Model(new(Repository)).Where("owner_id = ? AND fork_id = ?", userID, repoID).Count(&count)
+	return count > 0
+}

+ 65 - 0
internal/db/repos_test.go

@@ -101,6 +101,9 @@ func TestRepos(t *testing.T) {
 		{"GetByName", reposGetByName},
 		{"Star", reposStar},
 		{"Touch", reposTouch},
+		{"ListByRepo", reposListWatches},
+		{"Watch", reposWatch},
+		{"HasForkedBy", reposHasForkedBy},
 	} {
 		t.Run(tc.name, func(t *testing.T) {
 			t.Cleanup(func() {
@@ -298,3 +301,65 @@ func reposTouch(t *testing.T, db *repos) {
 	require.NoError(t, err)
 	assert.False(t, got.IsBare)
 }
+
+func reposListWatches(t *testing.T, db *repos) {
+	ctx := context.Background()
+
+	err := db.Watch(ctx, 1, 1)
+	require.NoError(t, err)
+	err = db.Watch(ctx, 2, 1)
+	require.NoError(t, err)
+	err = db.Watch(ctx, 2, 2)
+	require.NoError(t, err)
+
+	got, err := db.ListWatches(ctx, 1)
+	require.NoError(t, err)
+	for _, w := range got {
+		w.ID = 0
+	}
+
+	want := []*Watch{
+		{UserID: 1, RepoID: 1},
+		{UserID: 2, RepoID: 1},
+	}
+	assert.Equal(t, want, got)
+}
+
+func reposWatch(t *testing.T, db *repos) {
+	ctx := context.Background()
+
+	reposStore := NewReposStore(db.DB)
+	repo1, err := reposStore.Create(ctx, 1, CreateRepoOptions{Name: "repo1"})
+	require.NoError(t, err)
+
+	err = db.Watch(ctx, 2, repo1.ID)
+	require.NoError(t, err)
+
+	// It is OK to watch multiple times and just be noop.
+	err = db.Watch(ctx, 2, repo1.ID)
+	require.NoError(t, err)
+
+	repo1, err = reposStore.GetByID(ctx, repo1.ID)
+	require.NoError(t, err)
+	assert.Equal(t, 2, repo1.NumWatches) // The owner is watching the repo by default.
+}
+
+func reposHasForkedBy(t *testing.T, db *repos) {
+	ctx := context.Background()
+
+	has := db.HasForkedBy(ctx, 1, 2)
+	assert.False(t, has)
+
+	_, err := NewReposStore(db.DB).Create(
+		ctx,
+		2,
+		CreateRepoOptions{
+			Name:   "repo1",
+			ForkID: 1,
+		},
+	)
+	require.NoError(t, err)
+
+	has = db.HasForkedBy(ctx, 1, 2)
+	assert.True(t, has)
+}

+ 0 - 2
internal/db/two_factors.go

@@ -21,8 +21,6 @@ import (
 )
 
 // TwoFactorsStore is the persistent interface for 2FA.
-//
-// NOTE: All methods are sorted in alphabetical order.
 type TwoFactorsStore interface {
 	// Create creates a new 2FA token and recovery codes for given user. The "key"
 	// is used to encrypt and later decrypt given "secret", which should be

+ 133 - 43
internal/db/users.go

@@ -32,8 +32,6 @@ import (
 )
 
 // UsersStore is the persistent interface for users.
-//
-// NOTE: All methods are sorted in alphabetical order.
 type UsersStore interface {
 	// Authenticate validates username and password via given login source ID. It
 	// returns ErrUserNotExist when the user was not found.
@@ -47,29 +45,12 @@ type UsersStore interface {
 	// When the "loginSourceID" is positive, it tries to authenticate via given
 	// login source and creates a new user when not yet exists in the database.
 	Authenticate(ctx context.Context, username, password string, loginSourceID int64) (*User, error)
-	// ChangeUsername changes the username of the given user and updates all
-	// references to the old username. It returns ErrNameNotAllowed if the given
-	// name or pattern of the name is not allowed as a username, or
-	// ErrUserAlreadyExist when another user with same name already exists.
-	ChangeUsername(ctx context.Context, userID int64, newUsername string) error
-	// Count returns the total number of users.
-	Count(ctx context.Context) int64
 	// Create creates a new user and persists to database. It returns
 	// ErrNameNotAllowed if the given name or pattern of the name is not allowed as
 	// a username, or ErrUserAlreadyExist when a user with same name already exists,
 	// or ErrEmailAlreadyUsed if the email has been used by another user.
 	Create(ctx context.Context, username, email string, opts CreateUserOptions) (*User, error)
-	// DeleteCustomAvatar deletes the current user custom avatar and falls back to
-	// use look up avatar by email.
-	DeleteCustomAvatar(ctx context.Context, userID int64) error
-	// DeleteByID deletes the given user and all their resources. It returns
-	// ErrUserOwnRepos when the user still has repository ownership, or returns
-	// ErrUserHasOrgs when the user still has organization membership. It is more
-	// performant to skip rewriting the "authorized_keys" file for individual
-	// deletion in a batch operation.
-	DeleteByID(ctx context.Context, userID int64, skipRewriteAuthorizedKeys bool) error
-	// DeleteInactivated deletes all inactivated users.
-	DeleteInactivated() error
+
 	// GetByEmail returns the user (not organization) with given email. It ignores
 	// records with unverified emails and returns ErrUserNotExist when not found.
 	GetByEmail(ctx context.Context, email string) (*User, error)
@@ -86,15 +67,45 @@ type UsersStore interface {
 	// addresses (where email notifications are sent to) of users with given list of
 	// usernames. Non-existing usernames are ignored.
 	GetMailableEmailsByUsernames(ctx context.Context, usernames []string) ([]string, error)
-	// HasForkedRepository returns true if the user has forked given repository.
-	HasForkedRepository(ctx context.Context, userID, repoID int64) bool
+	// SearchByName returns a list of users whose username or full name matches the
+	// given keyword case-insensitively. Results are paginated by given page and
+	// page size, and sorted by the given order (e.g. "id DESC"). A total count of
+	// all results is also returned. If the order is not given, it's up to the
+	// database to decide.
+	SearchByName(ctx context.Context, keyword string, page, pageSize int, orderBy string) ([]*User, int64, error)
+
 	// IsUsernameUsed returns true if the given username has been used other than
 	// the excluded user (a non-positive ID effectively meaning check against all
 	// users).
 	IsUsernameUsed(ctx context.Context, username string, excludeUserId int64) bool
-	// List returns a list of users. Results are paginated by given page and page
-	// size, and sorted by primary key (id) in ascending order.
-	List(ctx context.Context, page, pageSize int) ([]*User, error)
+	// ChangeUsername changes the username of the given user and updates all
+	// references to the old username. It returns ErrNameNotAllowed if the given
+	// name or pattern of the name is not allowed as a username, or
+	// ErrUserAlreadyExist when another user with same name already exists.
+	ChangeUsername(ctx context.Context, userID int64, newUsername string) error
+	// Update updates fields for the given user.
+	Update(ctx context.Context, userID int64, opts UpdateUserOptions) error
+	// UseCustomAvatar uses the given avatar as the user custom avatar.
+	UseCustomAvatar(ctx context.Context, userID int64, avatar []byte) error
+
+	// DeleteCustomAvatar deletes the current user custom avatar and falls back to
+	// use look up avatar by email.
+	DeleteCustomAvatar(ctx context.Context, userID int64) error
+	// DeleteByID deletes the given user and all their resources. It returns
+	// ErrUserOwnRepos when the user still has repository ownership, or returns
+	// ErrUserHasOrgs when the user still has organization membership. It is more
+	// performant to skip rewriting the "authorized_keys" file for individual
+	// deletion in a batch operation.
+	DeleteByID(ctx context.Context, userID int64, skipRewriteAuthorizedKeys bool) error
+	// DeleteInactivated deletes all inactivated users.
+	DeleteInactivated() error
+
+	// Follow marks the user to follow the other user.
+	Follow(ctx context.Context, userID, followID int64) error
+	// Unfollow removes the mark the user to follow the other user.
+	Unfollow(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
 	// 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.
@@ -103,16 +114,12 @@ type UsersStore interface {
 	// 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)
-	// SearchByName returns a list of users whose username or full name matches the
-	// given keyword case-insensitively. Results are paginated by given page and
-	// page size, and sorted by the given order (e.g. "id DESC"). A total count of
-	// all results is also returned. If the order is not given, it's up to the
-	// database to decide.
-	SearchByName(ctx context.Context, keyword string, page, pageSize int, orderBy string) ([]*User, int64, error)
-	// Update updates fields for the given user.
-	Update(ctx context.Context, userID int64, opts UpdateUserOptions) error
-	// UseCustomAvatar uses the given avatar as the user custom avatar.
-	UseCustomAvatar(ctx context.Context, userID int64, avatar []byte) error
+
+	// List returns a list of users. Results are paginated by given page and page
+	// size, and sorted by primary key (id) in ascending order.
+	List(ctx context.Context, page, pageSize int) ([]*User, error)
+	// Count returns the total number of users.
+	Count(ctx context.Context) int64
 }
 
 var Users UsersStore
@@ -650,6 +657,88 @@ func (db *users) DeleteInactivated() error {
 	return nil
 }
 
+func (*users) recountFollows(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 "user.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 "user.num_following"`)
+	}
+	return nil
+}
+
+func (db *users) 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.recountFollows(tx, userID, followID)
+	})
+}
+
+func (db *users) 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.recountFollows(tx, userID, followID)
+	})
+}
+
+func (db *users) IsFollowing(ctx context.Context, userID, followID int64) bool {
+	return db.WithContext(ctx).Where("user_id = ? AND follow_id = ?", userID, followID).First(&Follow{}).Error == nil
+}
+
 var _ errutil.NotFound = (*ErrUserNotExist)(nil)
 
 type ErrUserNotExist struct {
@@ -757,12 +846,6 @@ func (db *users) GetMailableEmailsByUsernames(ctx context.Context, usernames []s
 		Find(&emails).Error
 }
 
-func (db *users) HasForkedRepository(ctx context.Context, userID, repoID int64) bool {
-	var count int64
-	db.WithContext(ctx).Model(new(Repository)).Where("owner_id = ? AND fork_id = ?", userID, repoID).Count(&count)
-	return count > 0
-}
-
 func (db *users) IsUsernameUsed(ctx context.Context, username string, excludeUserId int64) bool {
 	if username == "" {
 		return false
@@ -1181,7 +1264,7 @@ func (u *User) AvatarURL() string {
 // 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)
+	return Users.IsFollowing(context.TODO(), u.ID, followID)
 }
 
 // IsUserOrgOwner returns true if the user is in the owner team of the given
@@ -1208,7 +1291,7 @@ func (u *User) IsPublicMember(orgId int64) bool {
 // TODO(unknwon): This is also used in templates, which should be fixed by
 // having a dedicated type `template.User`.
 func (u *User) GetOrganizationCount() (int64, error) {
-	return OrgUsers.CountByUser(context.TODO(), u.ID)
+	return Orgs.CountByUser(context.TODO(), u.ID)
 }
 
 // ShortName truncates and returns the username at most in given length.
@@ -1336,3 +1419,10 @@ func isNameAllowed(names map[string]struct{}, patterns []string, name string) er
 func isUsernameAllowed(name string) error {
 	return isNameAllowed(reservedUsernames, reservedUsernamePatterns, name)
 }
+
+// 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"`
+}

+ 85 - 31
internal/db/users_test.go

@@ -107,7 +107,6 @@ func TestUsers(t *testing.T) {
 		{"GetByUsername", usersGetByUsername},
 		{"GetByKeyID", usersGetByKeyID},
 		{"GetMailableEmailsByUsernames", usersGetMailableEmailsByUsernames},
-		{"HasForkedRepository", usersHasForkedRepository},
 		{"IsUsernameUsed", usersIsUsernameUsed},
 		{"List", usersList},
 		{"ListFollowers", usersListFollowers},
@@ -115,6 +114,9 @@ func TestUsers(t *testing.T) {
 		{"SearchByName", usersSearchByName},
 		{"Update", usersUpdate},
 		{"UseCustomAvatar", usersUseCustomAvatar},
+		{"Follow", usersFollow},
+		{"IsFollowing", usersIsFollowing},
+		{"Unfollow", usersUnfollow},
 	} {
 		t.Run(tc.name, func(t *testing.T) {
 			t.Cleanup(func() {
@@ -518,14 +520,13 @@ func usersDeleteByID(t *testing.T, db *users) {
 	require.NoError(t, err)
 
 	// Mock watches, stars and follows
-	err = NewWatchesStore(db.DB).Watch(ctx, testUser.ID, repo2.ID)
+	err = reposStore.Watch(ctx, testUser.ID, repo2.ID)
 	require.NoError(t, err)
 	err = reposStore.Star(ctx, testUser.ID, repo2.ID)
 	require.NoError(t, err)
-	followsStore := NewFollowsStore(db.DB)
-	err = followsStore.Follow(ctx, testUser.ID, cindy.ID)
+	err = db.Follow(ctx, testUser.ID, cindy.ID)
 	require.NoError(t, err)
-	err = followsStore.Follow(ctx, frank.ID, testUser.ID)
+	err = db.Follow(ctx, frank.ID, testUser.ID)
 	require.NoError(t, err)
 
 	// Mock "authorized_keys" file
@@ -865,26 +866,6 @@ func usersGetMailableEmailsByUsernames(t *testing.T, db *users) {
 	assert.Equal(t, want, got)
 }
 
-func usersHasForkedRepository(t *testing.T, db *users) {
-	ctx := context.Background()
-
-	has := db.HasForkedRepository(ctx, 1, 1)
-	assert.False(t, has)
-
-	_, err := NewReposStore(db.DB).Create(
-		ctx,
-		1,
-		CreateRepoOptions{
-			Name:   "repo1",
-			ForkID: 1,
-		},
-	)
-	require.NoError(t, err)
-
-	has = db.HasForkedRepository(ctx, 1, 1)
-	assert.True(t, has)
-}
-
 func usersIsUsernameUsed(t *testing.T, db *users) {
 	ctx := context.Background()
 
@@ -987,10 +968,9 @@ func usersListFollowers(t *testing.T, db *users) {
 	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)
+	err = db.Follow(ctx, alice.ID, john.ID)
 	require.NoError(t, err)
-	err = followsStore.Follow(ctx, bob.ID, john.ID)
+	err = db.Follow(ctx, bob.ID, john.ID)
 	require.NoError(t, err)
 
 	// First page only has bob
@@ -1021,10 +1001,9 @@ func usersListFollowings(t *testing.T, db *users) {
 	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)
+	err = db.Follow(ctx, john.ID, alice.ID)
 	require.NoError(t, err)
-	err = followsStore.Follow(ctx, john.ID, bob.ID)
+	err = db.Follow(ctx, john.ID, bob.ID)
 	require.NoError(t, err)
 
 	// First page only has bob
@@ -1222,3 +1201,78 @@ func TestIsUsernameAllowed(t *testing.T) {
 		})
 	}
 }
+
+func usersFollow(t *testing.T, db *users) {
+	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 usersIsFollowing(t *testing.T, db *users) {
+	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 usersUnfollow(t *testing.T, db *users) {
+	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)
+}

+ 0 - 77
internal/db/watches.go

@@ -1,77 +0,0 @@
-// Copyright 2020 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"
-)
-
-// WatchesStore is the persistent interface for watches.
-//
-// NOTE: All methods are sorted in alphabetical order.
-type WatchesStore interface {
-	// ListByRepo returns all watches of the given repository.
-	ListByRepo(ctx context.Context, repoID int64) ([]*Watch, error)
-	// Watch marks the user to watch the repository.
-	Watch(ctx context.Context, userID, repoID int64) error
-}
-
-var Watches WatchesStore
-
-var _ WatchesStore = (*watches)(nil)
-
-type watches struct {
-	*gorm.DB
-}
-
-// NewWatchesStore returns a persistent interface for watches with given
-// database connection.
-func NewWatchesStore(db *gorm.DB) WatchesStore {
-	return &watches{DB: db}
-}
-
-func (db *watches) ListByRepo(ctx context.Context, repoID int64) ([]*Watch, error) {
-	var watches []*Watch
-	return watches, db.WithContext(ctx).Where("repo_id = ?", repoID).Find(&watches).Error
-}
-
-func (db *watches) updateWatchingCount(tx *gorm.DB, repoID int64) error {
-	/*
-		Equivalent SQL for PostgreSQL:
-
-		UPDATE repository
-		SET num_watches = (
-			SELECT COUNT(*) FROM watch WHERE repo_id = @repoID
-		)
-		WHERE id = @repoID
-	*/
-	return tx.Model(&Repository{}).
-		Where("id = ?", repoID).
-		Update(
-			"num_watches",
-			tx.Model(&Watch{}).Select("COUNT(*)").Where("repo_id = ?", repoID),
-		).
-		Error
-}
-
-func (db *watches) Watch(ctx context.Context, userID, repoID int64) error {
-	return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
-		w := &Watch{
-			UserID: userID,
-			RepoID: repoID,
-		}
-		result := tx.FirstOrCreate(w, w)
-		if result.Error != nil {
-			return errors.Wrap(result.Error, "upsert")
-		} else if result.RowsAffected <= 0 {
-			return nil // Relation already exists
-		}
-
-		return db.updateWatchingCount(tx, repoID)
-	})
-}

+ 0 - 88
internal/db/watches_test.go

@@ -1,88 +0,0 @@
-// 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 TestWatches(t *testing.T) {
-	if testing.Short() {
-		t.Skip()
-	}
-	t.Parallel()
-
-	tables := []any{new(Watch), new(Repository)}
-	db := &watches{
-		DB: dbtest.NewDB(t, "watches", tables...),
-	}
-
-	for _, tc := range []struct {
-		name string
-		test func(t *testing.T, db *watches)
-	}{
-		{"ListByRepo", watchesListByRepo},
-		{"Watch", watchesWatch},
-	} {
-		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 watchesListByRepo(t *testing.T, db *watches) {
-	ctx := context.Background()
-
-	err := db.Watch(ctx, 1, 1)
-	require.NoError(t, err)
-	err = db.Watch(ctx, 2, 1)
-	require.NoError(t, err)
-	err = db.Watch(ctx, 2, 2)
-	require.NoError(t, err)
-
-	got, err := db.ListByRepo(ctx, 1)
-	require.NoError(t, err)
-	for _, w := range got {
-		w.ID = 0
-	}
-
-	want := []*Watch{
-		{UserID: 1, RepoID: 1},
-		{UserID: 2, RepoID: 1},
-	}
-	assert.Equal(t, want, got)
-}
-
-func watchesWatch(t *testing.T, db *watches) {
-	ctx := context.Background()
-
-	reposStore := NewReposStore(db.DB)
-	repo1, err := reposStore.Create(ctx, 1, CreateRepoOptions{Name: "repo1"})
-	require.NoError(t, err)
-
-	err = db.Watch(ctx, 2, repo1.ID)
-	require.NoError(t, err)
-
-	// It is OK to watch multiple times and just be noop.
-	err = db.Watch(ctx, 2, repo1.ID)
-	require.NoError(t, err)
-
-	repo1, err = reposStore.GetByID(ctx, repo1.ID)
-	require.NoError(t, err)
-	assert.Equal(t, 2, repo1.NumWatches) // The owner is watching the repo by default.
-}

+ 0 - 2
internal/gitutil/module.go

@@ -9,8 +9,6 @@ import (
 )
 
 // ModuleStore is the interface for Git operations.
-//
-// NOTE: All methods are sorted in alphabetical order.
 type ModuleStore interface {
 	// RemoteAdd adds a new remote to the repository in given path.
 	RemoteAdd(repoPath, name, url string, opts ...git.RemoteAddOptions) error

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

@@ -62,7 +62,7 @@ func ListFollowing(c *context.APIContext) {
 }
 
 func checkUserFollowing(c *context.APIContext, u *db.User, followID int64) {
-	if db.Follows.IsFollowing(c.Req.Context(), u.ID, followID) {
+	if db.Users.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.Follows.Follow(c.Req.Context(), c.User.ID, target.ID); err != nil {
+	if err := db.Users.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.Follows.Unfollow(c.Req.Context(), c.User.ID, target.ID); err != nil {
+	if err := db.Users.Unfollow(c.Req.Context(), c.User.ID, target.ID); err != nil {
 		c.Error(err, "unfollow user")
 		return
 	}

+ 656 - 40
internal/route/lfs/mocks_test.go

@@ -1508,12 +1508,21 @@ type MockReposStore struct {
 	// GetByNameFunc is an instance of a mock function object controlling
 	// the behavior of the method GetByName.
 	GetByNameFunc *ReposStoreGetByNameFunc
+	// HasForkedByFunc is an instance of a mock function object controlling
+	// the behavior of the method HasForkedBy.
+	HasForkedByFunc *ReposStoreHasForkedByFunc
+	// ListWatchesFunc is an instance of a mock function object controlling
+	// the behavior of the method ListWatches.
+	ListWatchesFunc *ReposStoreListWatchesFunc
 	// StarFunc is an instance of a mock function object controlling the
 	// behavior of the method Star.
 	StarFunc *ReposStoreStarFunc
 	// TouchFunc is an instance of a mock function object controlling the
 	// behavior of the method Touch.
 	TouchFunc *ReposStoreTouchFunc
+	// WatchFunc is an instance of a mock function object controlling the
+	// behavior of the method Watch.
+	WatchFunc *ReposStoreWatchFunc
 }
 
 // NewMockReposStore creates a new mock of the ReposStore interface. All
@@ -1545,6 +1554,16 @@ func NewMockReposStore() *MockReposStore {
 				return
 			},
 		},
+		HasForkedByFunc: &ReposStoreHasForkedByFunc{
+			defaultHook: func(context.Context, int64, int64) (r0 bool) {
+				return
+			},
+		},
+		ListWatchesFunc: &ReposStoreListWatchesFunc{
+			defaultHook: func(context.Context, int64) (r0 []*db.Watch, r1 error) {
+				return
+			},
+		},
 		StarFunc: &ReposStoreStarFunc{
 			defaultHook: func(context.Context, int64, int64) (r0 error) {
 				return
@@ -1555,6 +1574,11 @@ func NewMockReposStore() *MockReposStore {
 				return
 			},
 		},
+		WatchFunc: &ReposStoreWatchFunc{
+			defaultHook: func(context.Context, int64, int64) (r0 error) {
+				return
+			},
+		},
 	}
 }
 
@@ -1587,6 +1611,16 @@ func NewStrictMockReposStore() *MockReposStore {
 				panic("unexpected invocation of MockReposStore.GetByName")
 			},
 		},
+		HasForkedByFunc: &ReposStoreHasForkedByFunc{
+			defaultHook: func(context.Context, int64, int64) bool {
+				panic("unexpected invocation of MockReposStore.HasForkedBy")
+			},
+		},
+		ListWatchesFunc: &ReposStoreListWatchesFunc{
+			defaultHook: func(context.Context, int64) ([]*db.Watch, error) {
+				panic("unexpected invocation of MockReposStore.ListWatches")
+			},
+		},
 		StarFunc: &ReposStoreStarFunc{
 			defaultHook: func(context.Context, int64, int64) error {
 				panic("unexpected invocation of MockReposStore.Star")
@@ -1597,6 +1631,11 @@ func NewStrictMockReposStore() *MockReposStore {
 				panic("unexpected invocation of MockReposStore.Touch")
 			},
 		},
+		WatchFunc: &ReposStoreWatchFunc{
+			defaultHook: func(context.Context, int64, int64) error {
+				panic("unexpected invocation of MockReposStore.Watch")
+			},
+		},
 	}
 }
 
@@ -1619,12 +1658,21 @@ func NewMockReposStoreFrom(i db.ReposStore) *MockReposStore {
 		GetByNameFunc: &ReposStoreGetByNameFunc{
 			defaultHook: i.GetByName,
 		},
+		HasForkedByFunc: &ReposStoreHasForkedByFunc{
+			defaultHook: i.HasForkedBy,
+		},
+		ListWatchesFunc: &ReposStoreListWatchesFunc{
+			defaultHook: i.ListWatches,
+		},
 		StarFunc: &ReposStoreStarFunc{
 			defaultHook: i.Star,
 		},
 		TouchFunc: &ReposStoreTouchFunc{
 			defaultHook: i.Touch,
 		},
+		WatchFunc: &ReposStoreWatchFunc{
+			defaultHook: i.Watch,
+		},
 	}
 }
 
@@ -2185,6 +2233,222 @@ func (c ReposStoreGetByNameFuncCall) Results() []interface{} {
 	return []interface{}{c.Result0, c.Result1}
 }
 
+// ReposStoreHasForkedByFunc describes the behavior when the HasForkedBy
+// method of the parent MockReposStore instance is invoked.
+type ReposStoreHasForkedByFunc struct {
+	defaultHook func(context.Context, int64, int64) bool
+	hooks       []func(context.Context, int64, int64) bool
+	history     []ReposStoreHasForkedByFuncCall
+	mutex       sync.Mutex
+}
+
+// HasForkedBy delegates to the next hook function in the queue and stores
+// the parameter and result values of this invocation.
+func (m *MockReposStore) HasForkedBy(v0 context.Context, v1 int64, v2 int64) bool {
+	r0 := m.HasForkedByFunc.nextHook()(v0, v1, v2)
+	m.HasForkedByFunc.appendCall(ReposStoreHasForkedByFuncCall{v0, v1, v2, r0})
+	return r0
+}
+
+// SetDefaultHook sets function that is called when the HasForkedBy method
+// of the parent MockReposStore instance is invoked and the hook queue is
+// empty.
+func (f *ReposStoreHasForkedByFunc) SetDefaultHook(hook func(context.Context, int64, int64) bool) {
+	f.defaultHook = hook
+}
+
+// PushHook adds a function to the end of hook queue. Each invocation of the
+// HasForkedBy method of the parent MockReposStore 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 *ReposStoreHasForkedByFunc) PushHook(hook func(context.Context, int64, int64) bool) {
+	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 *ReposStoreHasForkedByFunc) SetDefaultReturn(r0 bool) {
+	f.SetDefaultHook(func(context.Context, int64, int64) bool {
+		return r0
+	})
+}
+
+// PushReturn calls PushHook with a function that returns the given values.
+func (f *ReposStoreHasForkedByFunc) PushReturn(r0 bool) {
+	f.PushHook(func(context.Context, int64, int64) bool {
+		return r0
+	})
+}
+
+func (f *ReposStoreHasForkedByFunc) nextHook() func(context.Context, int64, int64) bool {
+	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 *ReposStoreHasForkedByFunc) appendCall(r0 ReposStoreHasForkedByFuncCall) {
+	f.mutex.Lock()
+	f.history = append(f.history, r0)
+	f.mutex.Unlock()
+}
+
+// History returns a sequence of ReposStoreHasForkedByFuncCall objects
+// describing the invocations of this function.
+func (f *ReposStoreHasForkedByFunc) History() []ReposStoreHasForkedByFuncCall {
+	f.mutex.Lock()
+	history := make([]ReposStoreHasForkedByFuncCall, len(f.history))
+	copy(history, f.history)
+	f.mutex.Unlock()
+
+	return history
+}
+
+// ReposStoreHasForkedByFuncCall is an object that describes an invocation
+// of method HasForkedBy on an instance of MockReposStore.
+type ReposStoreHasForkedByFuncCall 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 int64
+	// Result0 is the value of the 1st result returned from this method
+	// invocation.
+	Result0 bool
+}
+
+// Args returns an interface slice containing the arguments of this
+// invocation.
+func (c ReposStoreHasForkedByFuncCall) Args() []interface{} {
+	return []interface{}{c.Arg0, c.Arg1, c.Arg2}
+}
+
+// Results returns an interface slice containing the results of this
+// invocation.
+func (c ReposStoreHasForkedByFuncCall) Results() []interface{} {
+	return []interface{}{c.Result0}
+}
+
+// ReposStoreListWatchesFunc describes the behavior when the ListWatches
+// method of the parent MockReposStore instance is invoked.
+type ReposStoreListWatchesFunc struct {
+	defaultHook func(context.Context, int64) ([]*db.Watch, error)
+	hooks       []func(context.Context, int64) ([]*db.Watch, error)
+	history     []ReposStoreListWatchesFuncCall
+	mutex       sync.Mutex
+}
+
+// ListWatches delegates to the next hook function in the queue and stores
+// the parameter and result values of this invocation.
+func (m *MockReposStore) ListWatches(v0 context.Context, v1 int64) ([]*db.Watch, error) {
+	r0, r1 := m.ListWatchesFunc.nextHook()(v0, v1)
+	m.ListWatchesFunc.appendCall(ReposStoreListWatchesFuncCall{v0, v1, r0, r1})
+	return r0, r1
+}
+
+// SetDefaultHook sets function that is called when the ListWatches method
+// of the parent MockReposStore instance is invoked and the hook queue is
+// empty.
+func (f *ReposStoreListWatchesFunc) SetDefaultHook(hook func(context.Context, int64) ([]*db.Watch, error)) {
+	f.defaultHook = hook
+}
+
+// PushHook adds a function to the end of hook queue. Each invocation of the
+// ListWatches method of the parent MockReposStore 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 *ReposStoreListWatchesFunc) PushHook(hook func(context.Context, int64) ([]*db.Watch, 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 *ReposStoreListWatchesFunc) SetDefaultReturn(r0 []*db.Watch, r1 error) {
+	f.SetDefaultHook(func(context.Context, int64) ([]*db.Watch, error) {
+		return r0, r1
+	})
+}
+
+// PushReturn calls PushHook with a function that returns the given values.
+func (f *ReposStoreListWatchesFunc) PushReturn(r0 []*db.Watch, r1 error) {
+	f.PushHook(func(context.Context, int64) ([]*db.Watch, error) {
+		return r0, r1
+	})
+}
+
+func (f *ReposStoreListWatchesFunc) nextHook() func(context.Context, int64) ([]*db.Watch, 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 *ReposStoreListWatchesFunc) appendCall(r0 ReposStoreListWatchesFuncCall) {
+	f.mutex.Lock()
+	f.history = append(f.history, r0)
+	f.mutex.Unlock()
+}
+
+// History returns a sequence of ReposStoreListWatchesFuncCall objects
+// describing the invocations of this function.
+func (f *ReposStoreListWatchesFunc) History() []ReposStoreListWatchesFuncCall {
+	f.mutex.Lock()
+	history := make([]ReposStoreListWatchesFuncCall, len(f.history))
+	copy(history, f.history)
+	f.mutex.Unlock()
+
+	return history
+}
+
+// ReposStoreListWatchesFuncCall is an object that describes an invocation
+// of method ListWatches on an instance of MockReposStore.
+type ReposStoreListWatchesFuncCall 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
+	// Result0 is the value of the 1st result returned from this method
+	// invocation.
+	Result0 []*db.Watch
+	// 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 ReposStoreListWatchesFuncCall) Args() []interface{} {
+	return []interface{}{c.Arg0, c.Arg1}
+}
+
+// Results returns an interface slice containing the results of this
+// invocation.
+func (c ReposStoreListWatchesFuncCall) Results() []interface{} {
+	return []interface{}{c.Result0, c.Result1}
+}
+
 // ReposStoreStarFunc describes the behavior when the Star method of the
 // parent MockReposStore instance is invoked.
 type ReposStoreStarFunc struct {
@@ -2396,6 +2660,113 @@ func (c ReposStoreTouchFuncCall) Results() []interface{} {
 	return []interface{}{c.Result0}
 }
 
+// ReposStoreWatchFunc describes the behavior when the Watch method of the
+// parent MockReposStore instance is invoked.
+type ReposStoreWatchFunc struct {
+	defaultHook func(context.Context, int64, int64) error
+	hooks       []func(context.Context, int64, int64) error
+	history     []ReposStoreWatchFuncCall
+	mutex       sync.Mutex
+}
+
+// Watch delegates to the next hook function in the queue and stores the
+// parameter and result values of this invocation.
+func (m *MockReposStore) Watch(v0 context.Context, v1 int64, v2 int64) error {
+	r0 := m.WatchFunc.nextHook()(v0, v1, v2)
+	m.WatchFunc.appendCall(ReposStoreWatchFuncCall{v0, v1, v2, r0})
+	return r0
+}
+
+// SetDefaultHook sets function that is called when the Watch method of the
+// parent MockReposStore instance is invoked and the hook queue is empty.
+func (f *ReposStoreWatchFunc) SetDefaultHook(hook func(context.Context, int64, int64) error) {
+	f.defaultHook = hook
+}
+
+// PushHook adds a function to the end of hook queue. Each invocation of the
+// Watch method of the parent MockReposStore 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 *ReposStoreWatchFunc) PushHook(hook func(context.Context, int64, int64) 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 *ReposStoreWatchFunc) SetDefaultReturn(r0 error) {
+	f.SetDefaultHook(func(context.Context, int64, int64) error {
+		return r0
+	})
+}
+
+// PushReturn calls PushHook with a function that returns the given values.
+func (f *ReposStoreWatchFunc) PushReturn(r0 error) {
+	f.PushHook(func(context.Context, int64, int64) error {
+		return r0
+	})
+}
+
+func (f *ReposStoreWatchFunc) nextHook() func(context.Context, int64, int64) 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 *ReposStoreWatchFunc) appendCall(r0 ReposStoreWatchFuncCall) {
+	f.mutex.Lock()
+	f.history = append(f.history, r0)
+	f.mutex.Unlock()
+}
+
+// History returns a sequence of ReposStoreWatchFuncCall objects describing
+// the invocations of this function.
+func (f *ReposStoreWatchFunc) History() []ReposStoreWatchFuncCall {
+	f.mutex.Lock()
+	history := make([]ReposStoreWatchFuncCall, len(f.history))
+	copy(history, f.history)
+	f.mutex.Unlock()
+
+	return history
+}
+
+// ReposStoreWatchFuncCall is an object that describes an invocation of
+// method Watch on an instance of MockReposStore.
+type ReposStoreWatchFuncCall 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 int64
+	// Result0 is the value of the 1st result returned from this method
+	// invocation.
+	Result0 error
+}
+
+// Args returns an interface slice containing the arguments of this
+// invocation.
+func (c ReposStoreWatchFuncCall) Args() []interface{} {
+	return []interface{}{c.Arg0, c.Arg1, c.Arg2}
+}
+
+// Results returns an interface slice containing the results of this
+// invocation.
+func (c ReposStoreWatchFuncCall) Results() []interface{} {
+	return []interface{}{c.Result0}
+}
+
 // MockTwoFactorsStore is a mock implementation of the TwoFactorsStore
 // interface (from the package gogs.io/gogs/internal/db) used for unit
 // testing.
@@ -2821,6 +3192,9 @@ type MockUsersStore struct {
 	// DeleteInactivatedFunc is an instance of a mock function object
 	// controlling the behavior of the method DeleteInactivated.
 	DeleteInactivatedFunc *UsersStoreDeleteInactivatedFunc
+	// FollowFunc is an instance of a mock function object controlling the
+	// behavior of the method Follow.
+	FollowFunc *UsersStoreFollowFunc
 	// GetByEmailFunc is an instance of a mock function object controlling
 	// the behavior of the method GetByEmail.
 	GetByEmailFunc *UsersStoreGetByEmailFunc
@@ -2837,9 +3211,9 @@ type MockUsersStore struct {
 	// object controlling the behavior of the method
 	// GetMailableEmailsByUsernames.
 	GetMailableEmailsByUsernamesFunc *UsersStoreGetMailableEmailsByUsernamesFunc
-	// HasForkedRepositoryFunc is an instance of a mock function object
-	// controlling the behavior of the method HasForkedRepository.
-	HasForkedRepositoryFunc *UsersStoreHasForkedRepositoryFunc
+	// IsFollowingFunc is an instance of a mock function object controlling
+	// the behavior of the method IsFollowing.
+	IsFollowingFunc *UsersStoreIsFollowingFunc
 	// IsUsernameUsedFunc is an instance of a mock function object
 	// controlling the behavior of the method IsUsernameUsed.
 	IsUsernameUsedFunc *UsersStoreIsUsernameUsedFunc
@@ -2855,6 +3229,9 @@ type MockUsersStore struct {
 	// SearchByNameFunc is an instance of a mock function object controlling
 	// the behavior of the method SearchByName.
 	SearchByNameFunc *UsersStoreSearchByNameFunc
+	// UnfollowFunc is an instance of a mock function object controlling the
+	// behavior of the method Unfollow.
+	UnfollowFunc *UsersStoreUnfollowFunc
 	// UpdateFunc is an instance of a mock function object controlling the
 	// behavior of the method Update.
 	UpdateFunc *UsersStoreUpdateFunc
@@ -2902,6 +3279,11 @@ func NewMockUsersStore() *MockUsersStore {
 				return
 			},
 		},
+		FollowFunc: &UsersStoreFollowFunc{
+			defaultHook: func(context.Context, int64, int64) (r0 error) {
+				return
+			},
+		},
 		GetByEmailFunc: &UsersStoreGetByEmailFunc{
 			defaultHook: func(context.Context, string) (r0 *db.User, r1 error) {
 				return
@@ -2927,7 +3309,7 @@ func NewMockUsersStore() *MockUsersStore {
 				return
 			},
 		},
-		HasForkedRepositoryFunc: &UsersStoreHasForkedRepositoryFunc{
+		IsFollowingFunc: &UsersStoreIsFollowingFunc{
 			defaultHook: func(context.Context, int64, int64) (r0 bool) {
 				return
 			},
@@ -2957,6 +3339,11 @@ func NewMockUsersStore() *MockUsersStore {
 				return
 			},
 		},
+		UnfollowFunc: &UsersStoreUnfollowFunc{
+			defaultHook: func(context.Context, int64, int64) (r0 error) {
+				return
+			},
+		},
 		UpdateFunc: &UsersStoreUpdateFunc{
 			defaultHook: func(context.Context, int64, db.UpdateUserOptions) (r0 error) {
 				return
@@ -3009,6 +3396,11 @@ func NewStrictMockUsersStore() *MockUsersStore {
 				panic("unexpected invocation of MockUsersStore.DeleteInactivated")
 			},
 		},
+		FollowFunc: &UsersStoreFollowFunc{
+			defaultHook: func(context.Context, int64, int64) error {
+				panic("unexpected invocation of MockUsersStore.Follow")
+			},
+		},
 		GetByEmailFunc: &UsersStoreGetByEmailFunc{
 			defaultHook: func(context.Context, string) (*db.User, error) {
 				panic("unexpected invocation of MockUsersStore.GetByEmail")
@@ -3034,9 +3426,9 @@ func NewStrictMockUsersStore() *MockUsersStore {
 				panic("unexpected invocation of MockUsersStore.GetMailableEmailsByUsernames")
 			},
 		},
-		HasForkedRepositoryFunc: &UsersStoreHasForkedRepositoryFunc{
+		IsFollowingFunc: &UsersStoreIsFollowingFunc{
 			defaultHook: func(context.Context, int64, int64) bool {
-				panic("unexpected invocation of MockUsersStore.HasForkedRepository")
+				panic("unexpected invocation of MockUsersStore.IsFollowing")
 			},
 		},
 		IsUsernameUsedFunc: &UsersStoreIsUsernameUsedFunc{
@@ -3064,6 +3456,11 @@ func NewStrictMockUsersStore() *MockUsersStore {
 				panic("unexpected invocation of MockUsersStore.SearchByName")
 			},
 		},
+		UnfollowFunc: &UsersStoreUnfollowFunc{
+			defaultHook: func(context.Context, int64, int64) error {
+				panic("unexpected invocation of MockUsersStore.Unfollow")
+			},
+		},
 		UpdateFunc: &UsersStoreUpdateFunc{
 			defaultHook: func(context.Context, int64, db.UpdateUserOptions) error {
 				panic("unexpected invocation of MockUsersStore.Update")
@@ -3102,6 +3499,9 @@ func NewMockUsersStoreFrom(i db.UsersStore) *MockUsersStore {
 		DeleteInactivatedFunc: &UsersStoreDeleteInactivatedFunc{
 			defaultHook: i.DeleteInactivated,
 		},
+		FollowFunc: &UsersStoreFollowFunc{
+			defaultHook: i.Follow,
+		},
 		GetByEmailFunc: &UsersStoreGetByEmailFunc{
 			defaultHook: i.GetByEmail,
 		},
@@ -3117,8 +3517,8 @@ func NewMockUsersStoreFrom(i db.UsersStore) *MockUsersStore {
 		GetMailableEmailsByUsernamesFunc: &UsersStoreGetMailableEmailsByUsernamesFunc{
 			defaultHook: i.GetMailableEmailsByUsernames,
 		},
-		HasForkedRepositoryFunc: &UsersStoreHasForkedRepositoryFunc{
-			defaultHook: i.HasForkedRepository,
+		IsFollowingFunc: &UsersStoreIsFollowingFunc{
+			defaultHook: i.IsFollowing,
 		},
 		IsUsernameUsedFunc: &UsersStoreIsUsernameUsedFunc{
 			defaultHook: i.IsUsernameUsed,
@@ -3135,6 +3535,9 @@ func NewMockUsersStoreFrom(i db.UsersStore) *MockUsersStore {
 		SearchByNameFunc: &UsersStoreSearchByNameFunc{
 			defaultHook: i.SearchByName,
 		},
+		UnfollowFunc: &UsersStoreUnfollowFunc{
+			defaultHook: i.Unfollow,
+		},
 		UpdateFunc: &UsersStoreUpdateFunc{
 			defaultHook: i.Update,
 		},
@@ -3894,6 +4297,113 @@ func (c UsersStoreDeleteInactivatedFuncCall) Results() []interface{} {
 	return []interface{}{c.Result0}
 }
 
+// UsersStoreFollowFunc describes the behavior when the Follow method of the
+// parent MockUsersStore instance is invoked.
+type UsersStoreFollowFunc struct {
+	defaultHook func(context.Context, int64, int64) error
+	hooks       []func(context.Context, int64, int64) error
+	history     []UsersStoreFollowFuncCall
+	mutex       sync.Mutex
+}
+
+// Follow delegates to the next hook function in the queue and stores the
+// parameter and result values of this invocation.
+func (m *MockUsersStore) Follow(v0 context.Context, v1 int64, v2 int64) error {
+	r0 := m.FollowFunc.nextHook()(v0, v1, v2)
+	m.FollowFunc.appendCall(UsersStoreFollowFuncCall{v0, v1, v2, r0})
+	return r0
+}
+
+// SetDefaultHook sets function that is called when the Follow method of the
+// parent MockUsersStore instance is invoked and the hook queue is empty.
+func (f *UsersStoreFollowFunc) SetDefaultHook(hook func(context.Context, int64, int64) error) {
+	f.defaultHook = hook
+}
+
+// PushHook adds a function to the end of hook queue. Each invocation of the
+// Follow 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 *UsersStoreFollowFunc) PushHook(hook func(context.Context, int64, int64) 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 *UsersStoreFollowFunc) SetDefaultReturn(r0 error) {
+	f.SetDefaultHook(func(context.Context, int64, int64) error {
+		return r0
+	})
+}
+
+// PushReturn calls PushHook with a function that returns the given values.
+func (f *UsersStoreFollowFunc) PushReturn(r0 error) {
+	f.PushHook(func(context.Context, int64, int64) error {
+		return r0
+	})
+}
+
+func (f *UsersStoreFollowFunc) nextHook() func(context.Context, int64, int64) 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 *UsersStoreFollowFunc) appendCall(r0 UsersStoreFollowFuncCall) {
+	f.mutex.Lock()
+	f.history = append(f.history, r0)
+	f.mutex.Unlock()
+}
+
+// History returns a sequence of UsersStoreFollowFuncCall objects describing
+// the invocations of this function.
+func (f *UsersStoreFollowFunc) History() []UsersStoreFollowFuncCall {
+	f.mutex.Lock()
+	history := make([]UsersStoreFollowFuncCall, len(f.history))
+	copy(history, f.history)
+	f.mutex.Unlock()
+
+	return history
+}
+
+// UsersStoreFollowFuncCall is an object that describes an invocation of
+// method Follow on an instance of MockUsersStore.
+type UsersStoreFollowFuncCall 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 int64
+	// Result0 is the value of the 1st result returned from this method
+	// invocation.
+	Result0 error
+}
+
+// Args returns an interface slice containing the arguments of this
+// invocation.
+func (c UsersStoreFollowFuncCall) Args() []interface{} {
+	return []interface{}{c.Arg0, c.Arg1, c.Arg2}
+}
+
+// Results returns an interface slice containing the results of this
+// invocation.
+func (c UsersStoreFollowFuncCall) Results() []interface{} {
+	return []interface{}{c.Result0}
+}
+
 // UsersStoreGetByEmailFunc describes the behavior when the GetByEmail
 // method of the parent MockUsersStore instance is invoked.
 type UsersStoreGetByEmailFunc struct {
@@ -4438,36 +4948,35 @@ func (c UsersStoreGetMailableEmailsByUsernamesFuncCall) Results() []interface{}
 	return []interface{}{c.Result0, c.Result1}
 }
 
-// UsersStoreHasForkedRepositoryFunc describes the behavior when the
-// HasForkedRepository method of the parent MockUsersStore instance is
-// invoked.
-type UsersStoreHasForkedRepositoryFunc struct {
+// UsersStoreIsFollowingFunc describes the behavior when the IsFollowing
+// method of the parent MockUsersStore instance is invoked.
+type UsersStoreIsFollowingFunc struct {
 	defaultHook func(context.Context, int64, int64) bool
 	hooks       []func(context.Context, int64, int64) bool
-	history     []UsersStoreHasForkedRepositoryFuncCall
+	history     []UsersStoreIsFollowingFuncCall
 	mutex       sync.Mutex
 }
 
-// HasForkedRepository delegates to the next hook function in the queue and
-// stores the parameter and result values of this invocation.
-func (m *MockUsersStore) HasForkedRepository(v0 context.Context, v1 int64, v2 int64) bool {
-	r0 := m.HasForkedRepositoryFunc.nextHook()(v0, v1, v2)
-	m.HasForkedRepositoryFunc.appendCall(UsersStoreHasForkedRepositoryFuncCall{v0, v1, v2, r0})
+// IsFollowing delegates to the next hook function in the queue and stores
+// the parameter and result values of this invocation.
+func (m *MockUsersStore) IsFollowing(v0 context.Context, v1 int64, v2 int64) bool {
+	r0 := m.IsFollowingFunc.nextHook()(v0, v1, v2)
+	m.IsFollowingFunc.appendCall(UsersStoreIsFollowingFuncCall{v0, v1, v2, r0})
 	return r0
 }
 
-// SetDefaultHook sets function that is called when the HasForkedRepository
-// method of the parent MockUsersStore instance is invoked and the hook
-// queue is empty.
-func (f *UsersStoreHasForkedRepositoryFunc) SetDefaultHook(hook func(context.Context, int64, int64) bool) {
+// SetDefaultHook sets function that is called when the IsFollowing method
+// of the parent MockUsersStore instance is invoked and the hook queue is
+// empty.
+func (f *UsersStoreIsFollowingFunc) SetDefaultHook(hook func(context.Context, int64, int64) bool) {
 	f.defaultHook = hook
 }
 
 // PushHook adds a function to the end of hook queue. Each invocation of the
-// HasForkedRepository 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 *UsersStoreHasForkedRepositoryFunc) PushHook(hook func(context.Context, int64, int64) bool) {
+// IsFollowing 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 *UsersStoreIsFollowingFunc) PushHook(hook func(context.Context, int64, int64) bool) {
 	f.mutex.Lock()
 	f.hooks = append(f.hooks, hook)
 	f.mutex.Unlock()
@@ -4475,20 +4984,20 @@ func (f *UsersStoreHasForkedRepositoryFunc) PushHook(hook func(context.Context,
 
 // SetDefaultReturn calls SetDefaultHook with a function that returns the
 // given values.
-func (f *UsersStoreHasForkedRepositoryFunc) SetDefaultReturn(r0 bool) {
+func (f *UsersStoreIsFollowingFunc) SetDefaultReturn(r0 bool) {
 	f.SetDefaultHook(func(context.Context, int64, int64) bool {
 		return r0
 	})
 }
 
 // PushReturn calls PushHook with a function that returns the given values.
-func (f *UsersStoreHasForkedRepositoryFunc) PushReturn(r0 bool) {
+func (f *UsersStoreIsFollowingFunc) PushReturn(r0 bool) {
 	f.PushHook(func(context.Context, int64, int64) bool {
 		return r0
 	})
 }
 
-func (f *UsersStoreHasForkedRepositoryFunc) nextHook() func(context.Context, int64, int64) bool {
+func (f *UsersStoreIsFollowingFunc) nextHook() func(context.Context, int64, int64) bool {
 	f.mutex.Lock()
 	defer f.mutex.Unlock()
 
@@ -4501,27 +5010,26 @@ func (f *UsersStoreHasForkedRepositoryFunc) nextHook() func(context.Context, int
 	return hook
 }
 
-func (f *UsersStoreHasForkedRepositoryFunc) appendCall(r0 UsersStoreHasForkedRepositoryFuncCall) {
+func (f *UsersStoreIsFollowingFunc) appendCall(r0 UsersStoreIsFollowingFuncCall) {
 	f.mutex.Lock()
 	f.history = append(f.history, r0)
 	f.mutex.Unlock()
 }
 
-// History returns a sequence of UsersStoreHasForkedRepositoryFuncCall
-// objects describing the invocations of this function.
-func (f *UsersStoreHasForkedRepositoryFunc) History() []UsersStoreHasForkedRepositoryFuncCall {
+// History returns a sequence of UsersStoreIsFollowingFuncCall objects
+// describing the invocations of this function.
+func (f *UsersStoreIsFollowingFunc) History() []UsersStoreIsFollowingFuncCall {
 	f.mutex.Lock()
-	history := make([]UsersStoreHasForkedRepositoryFuncCall, len(f.history))
+	history := make([]UsersStoreIsFollowingFuncCall, len(f.history))
 	copy(history, f.history)
 	f.mutex.Unlock()
 
 	return history
 }
 
-// UsersStoreHasForkedRepositoryFuncCall is an object that describes an
-// invocation of method HasForkedRepository on an instance of
-// MockUsersStore.
-type UsersStoreHasForkedRepositoryFuncCall struct {
+// UsersStoreIsFollowingFuncCall is an object that describes an invocation
+// of method IsFollowing on an instance of MockUsersStore.
+type UsersStoreIsFollowingFuncCall struct {
 	// Arg0 is the value of the 1st argument passed to this method
 	// invocation.
 	Arg0 context.Context
@@ -4538,13 +5046,13 @@ type UsersStoreHasForkedRepositoryFuncCall struct {
 
 // Args returns an interface slice containing the arguments of this
 // invocation.
-func (c UsersStoreHasForkedRepositoryFuncCall) Args() []interface{} {
+func (c UsersStoreIsFollowingFuncCall) Args() []interface{} {
 	return []interface{}{c.Arg0, c.Arg1, c.Arg2}
 }
 
 // Results returns an interface slice containing the results of this
 // invocation.
-func (c UsersStoreHasForkedRepositoryFuncCall) Results() []interface{} {
+func (c UsersStoreIsFollowingFuncCall) Results() []interface{} {
 	return []interface{}{c.Result0}
 }
 
@@ -5114,6 +5622,114 @@ func (c UsersStoreSearchByNameFuncCall) Results() []interface{} {
 	return []interface{}{c.Result0, c.Result1, c.Result2}
 }
 
+// UsersStoreUnfollowFunc describes the behavior when the Unfollow method of
+// the parent MockUsersStore instance is invoked.
+type UsersStoreUnfollowFunc struct {
+	defaultHook func(context.Context, int64, int64) error
+	hooks       []func(context.Context, int64, int64) error
+	history     []UsersStoreUnfollowFuncCall
+	mutex       sync.Mutex
+}
+
+// Unfollow delegates to the next hook function in the queue and stores the
+// parameter and result values of this invocation.
+func (m *MockUsersStore) Unfollow(v0 context.Context, v1 int64, v2 int64) error {
+	r0 := m.UnfollowFunc.nextHook()(v0, v1, v2)
+	m.UnfollowFunc.appendCall(UsersStoreUnfollowFuncCall{v0, v1, v2, r0})
+	return r0
+}
+
+// SetDefaultHook sets function that is called when the Unfollow method of
+// the parent MockUsersStore instance is invoked and the hook queue is
+// empty.
+func (f *UsersStoreUnfollowFunc) SetDefaultHook(hook func(context.Context, int64, int64) error) {
+	f.defaultHook = hook
+}
+
+// PushHook adds a function to the end of hook queue. Each invocation of the
+// Unfollow 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 *UsersStoreUnfollowFunc) PushHook(hook func(context.Context, int64, int64) 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 *UsersStoreUnfollowFunc) SetDefaultReturn(r0 error) {
+	f.SetDefaultHook(func(context.Context, int64, int64) error {
+		return r0
+	})
+}
+
+// PushReturn calls PushHook with a function that returns the given values.
+func (f *UsersStoreUnfollowFunc) PushReturn(r0 error) {
+	f.PushHook(func(context.Context, int64, int64) error {
+		return r0
+	})
+}
+
+func (f *UsersStoreUnfollowFunc) nextHook() func(context.Context, int64, int64) 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 *UsersStoreUnfollowFunc) appendCall(r0 UsersStoreUnfollowFuncCall) {
+	f.mutex.Lock()
+	f.history = append(f.history, r0)
+	f.mutex.Unlock()
+}
+
+// History returns a sequence of UsersStoreUnfollowFuncCall objects
+// describing the invocations of this function.
+func (f *UsersStoreUnfollowFunc) History() []UsersStoreUnfollowFuncCall {
+	f.mutex.Lock()
+	history := make([]UsersStoreUnfollowFuncCall, len(f.history))
+	copy(history, f.history)
+	f.mutex.Unlock()
+
+	return history
+}
+
+// UsersStoreUnfollowFuncCall is an object that describes an invocation of
+// method Unfollow on an instance of MockUsersStore.
+type UsersStoreUnfollowFuncCall 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 int64
+	// Result0 is the value of the 1st result returned from this method
+	// invocation.
+	Result0 error
+}
+
+// Args returns an interface slice containing the arguments of this
+// invocation.
+func (c UsersStoreUnfollowFuncCall) Args() []interface{} {
+	return []interface{}{c.Arg0, c.Arg1, c.Arg2}
+}
+
+// Results returns an interface slice containing the results of this
+// invocation.
+func (c UsersStoreUnfollowFuncCall) Results() []interface{} {
+	return []interface{}{c.Result0}
+}
+
 // UsersStoreUpdateFunc describes the behavior when the Update method of the
 // parent MockUsersStore instance is invoked.
 type UsersStoreUpdateFunc struct {

+ 1 - 1
internal/route/repo/issue.go

@@ -68,7 +68,7 @@ func MustAllowPulls(c *context.Context) {
 	}
 
 	// User can send pull request if owns a forked repository.
-	if c.IsLogged && db.Users.HasForkedRepository(c.Req.Context(), c.User.ID, c.Repo.Repository.ID) {
+	if c.IsLogged && db.Repos.HasForkedBy(c.Req.Context(), c.Repo.Repository.ID, c.User.ID) {
 		c.Repo.PullRequest.Allowed = true
 		c.Repo.PullRequest.HeadInfo = c.User.Name + ":" + c.Repo.BranchName
 	}

+ 2 - 2
internal/route/user/profile.go

@@ -120,9 +120,9 @@ func Action(c *context.Context, puser *context.ParamsUser) {
 	var err error
 	switch c.Params(":action") {
 	case "follow":
-		err = db.Follows.Follow(c.Req.Context(), c.UserID(), puser.ID)
+		err = db.Users.Follow(c.Req.Context(), c.UserID(), puser.ID)
 	case "unfollow":
-		err = db.Follows.Unfollow(c.Req.Context(), c.UserID(), puser.ID)
+		err = db.Users.Unfollow(c.Req.Context(), c.UserID(), puser.ID)
 	}
 
 	if err != nil {