jojo/models/issues/reaction.go
Mathieu Fenniak 9abc1b0144 refactor: reduce code duplication when accessing DefaultMaxInSize (#11999)
`DefaultMaxInSize` is an internal parameter for limiting the size of `field IN (...)` clauses in DB queries, which is a reasonable thing to do -- in addition to the errors noted when [originally introduced](https://github.com/go-gitea/gitea/pull/4594), there are technical limits that apply to each of PostgreSQL, MySQL, and SQLite which would prevent an unbounded size for a query like this.  However: the size is incredibly small at 50, and, the implementation of `DefaultMaxInSize` is really wasteful with copy-and-paste coding.

This PR:
- introduces `GetByIDs` which fetches a `map[int64]*Model` from the database for an array of ID values, while respecting `IN` clause size limits
- introduces `GetByFieldIn` which fetches a `map[int64][]*Model` from the database for an array of field values, while respecting `IN` clause size limits
- uses `slices.Chunk` for other locations where queries are too complex for these implementations
- bumps the `DefaultMaxInSize` parameter from 50 to 500, a conservative increase well under known limits, but 10x the current value:
    - PostgreSQL supports up to 1GB query text size with 65,535 parameters, but I've experienced performance degradation at high value counts
    - MySQL supports 64MB query text size without known limits of parameter count
    - SQLite supports 32,766 parameters in a query

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. All work and communication must conform to Forgejo's [AI Agreement](https://codeberg.org/forgejo/governance/src/branch/main/AIAgreement.md). There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests for Go changes

- I added test coverage for Go changes...
  - [x] in their respective `*_test.go` for unit tests.
      - Refactored functions are assumed to be covered by existing tests to some extent; that assumption is probably wrong but the changes here are relatively easily reviewed for correctness as well.
  - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I ran...
  - [x] `make pr-go` before pushing

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [x] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [ ] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change.
- [x] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11999
Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
Co-authored-by: Mathieu Fenniak <mathieu@fenniak.net>
Co-committed-by: Mathieu Fenniak <mathieu@fenniak.net>
2026-04-05 22:03:45 +02:00

402 lines
11 KiB
Go

// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"bytes"
"context"
"fmt"
"slices"
"forgejo.org/models/db"
repo_model "forgejo.org/models/repo"
user_model "forgejo.org/models/user"
"forgejo.org/modules/container"
"forgejo.org/modules/setting"
"forgejo.org/modules/timeutil"
"forgejo.org/modules/util"
"xorm.io/builder"
)
// ErrForbiddenIssueReaction is used when a forbidden reaction was try to created
type ErrForbiddenIssueReaction struct {
Reaction string
}
// IsErrForbiddenIssueReaction checks if an error is a ErrForbiddenIssueReaction.
func IsErrForbiddenIssueReaction(err error) bool {
_, ok := err.(ErrForbiddenIssueReaction)
return ok
}
func (err ErrForbiddenIssueReaction) Error() string {
return fmt.Sprintf("'%s' is not an allowed reaction", err.Reaction)
}
func (err ErrForbiddenIssueReaction) Unwrap() error {
return util.ErrPermissionDenied
}
// ErrReactionAlreadyExist is used when a existing reaction was try to created
type ErrReactionAlreadyExist struct {
Reaction string
}
// IsErrReactionAlreadyExist checks if an error is a ErrReactionAlreadyExist.
func IsErrReactionAlreadyExist(err error) bool {
_, ok := err.(ErrReactionAlreadyExist)
return ok
}
func (err ErrReactionAlreadyExist) Error() string {
return fmt.Sprintf("reaction '%s' already exists", err.Reaction)
}
func (err ErrReactionAlreadyExist) Unwrap() error {
return util.ErrAlreadyExist
}
// Reaction represents a reactions on issues and comments.
type Reaction struct {
ID int64 `xorm:"pk autoincr"`
Type string `xorm:"INDEX UNIQUE(s) NOT NULL"`
IssueID int64 `xorm:"INDEX UNIQUE(s) NOT NULL"`
CommentID int64 `xorm:"INDEX UNIQUE(s)"`
UserID int64 `xorm:"INDEX UNIQUE(s) NOT NULL"`
OriginalAuthorID int64 `xorm:"INDEX UNIQUE(s) NOT NULL DEFAULT(0)"`
OriginalAuthor string `xorm:"INDEX UNIQUE(s)"`
User *user_model.User `xorm:"-"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
}
// LoadUser load user of reaction
func (r *Reaction) LoadUser(ctx context.Context) (*user_model.User, error) {
if r.User != nil {
return r.User, nil
}
user, err := user_model.GetUserByID(ctx, r.UserID)
if err != nil {
return nil, err
}
r.User = user
return user, nil
}
// RemapExternalUser ExternalUserRemappable interface
func (r *Reaction) RemapExternalUser(externalName string, externalID, userID int64) error {
r.OriginalAuthor = externalName
r.OriginalAuthorID = externalID
r.UserID = userID
return nil
}
// GetUserID ExternalUserRemappable interface
func (r *Reaction) GetUserID() int64 { return r.UserID }
// GetExternalName ExternalUserRemappable interface
func (r *Reaction) GetExternalName() string { return r.OriginalAuthor }
// GetExternalID ExternalUserRemappable interface
func (r *Reaction) GetExternalID() int64 { return r.OriginalAuthorID }
func init() {
db.RegisterModel(new(Reaction))
}
// FindReactionsOptions describes the conditions to Find reactions
type FindReactionsOptions struct {
db.ListOptions
IssueID int64
CommentID int64
UserID int64
Reaction string
}
func (opts *FindReactionsOptions) toConds() builder.Cond {
// If Issue ID is set add to Query
cond := builder.NewCond()
if opts.IssueID > 0 {
cond = cond.And(builder.Eq{"reaction.issue_id": opts.IssueID})
}
// If CommentID is > 0 add to Query
// If it is 0 Query ignore CommentID to select
// If it is -1 it explicit search of Issue Reactions where CommentID = 0
if opts.CommentID > 0 {
cond = cond.And(builder.Eq{"reaction.comment_id": opts.CommentID})
} else if opts.CommentID == -1 {
cond = cond.And(builder.Eq{"reaction.comment_id": 0})
}
if opts.UserID > 0 {
cond = cond.And(builder.Eq{
"reaction.user_id": opts.UserID,
"reaction.original_author_id": 0,
})
}
if opts.Reaction != "" {
cond = cond.And(builder.Eq{"reaction.type": opts.Reaction})
}
return cond
}
// FindCommentReactions returns a ReactionList of all reactions from an comment
func FindCommentReactions(ctx context.Context, issueID, commentID int64) (ReactionList, int64, error) {
return FindReactions(ctx, FindReactionsOptions{
IssueID: issueID,
CommentID: commentID,
})
}
// FindIssueReactions returns a ReactionList of all reactions from an issue
func FindIssueReactions(ctx context.Context, issueID int64, listOptions db.ListOptions) (ReactionList, int64, error) {
return FindReactions(ctx, FindReactionsOptions{
ListOptions: listOptions,
IssueID: issueID,
CommentID: -1,
})
}
// FindReactions returns a ReactionList of all reactions from an issue or a comment
func FindReactions(ctx context.Context, opts FindReactionsOptions) (ReactionList, int64, error) {
sess := db.GetEngine(ctx).
Where(opts.toConds()).
In("reaction.`type`", setting.UI.Reactions).
Asc("reaction.issue_id", "reaction.comment_id", "reaction.created_unix", "reaction.id")
if opts.Page > 0 {
sess = db.SetSessionPagination(sess, &opts)
reactions := make([]*Reaction, 0, opts.PageSize)
count, err := sess.FindAndCount(&reactions)
return reactions, count, err
}
reactions := make([]*Reaction, 0, 10)
count, err := sess.FindAndCount(&reactions)
return reactions, count, err
}
func getReactionsForComments(ctx context.Context, issueID int64, commentIDs []int64) (map[int64]ReactionList, error) {
reactions := make(map[int64]ReactionList, len(commentIDs))
for commentIDChunk := range slices.Chunk(commentIDs, db.DefaultMaxInSize) {
rows, err := db.GetEngine(ctx).
Where(builder.Eq{"issue_id": issueID}).
In("reaction.`type`", setting.UI.Reactions).
In("comment_id", commentIDChunk).
Rows(&Reaction{})
if err != nil {
return nil, err
}
for rows.Next() {
var reaction Reaction
err = rows.Scan(&reaction)
if err != nil {
_ = rows.Close()
return nil, err
}
reactions[reaction.CommentID] = append(reactions[reaction.CommentID], &reaction)
}
_ = rows.Close()
}
return reactions, nil
}
func createReaction(ctx context.Context, opts *ReactionOptions) (*Reaction, error) {
reaction := &Reaction{
Type: opts.Type,
UserID: opts.DoerID,
IssueID: opts.IssueID,
CommentID: opts.CommentID,
}
findOpts := FindReactionsOptions{
IssueID: opts.IssueID,
CommentID: opts.CommentID,
Reaction: opts.Type,
UserID: opts.DoerID,
}
if findOpts.CommentID == 0 {
// explicit search of Issue Reactions where CommentID = 0
findOpts.CommentID = -1
}
existingR, _, err := FindReactions(ctx, findOpts)
if err != nil {
return nil, err
}
if len(existingR) > 0 {
return existingR[0], ErrReactionAlreadyExist{Reaction: opts.Type}
}
if err := db.Insert(ctx, reaction); err != nil {
return nil, err
}
return reaction, nil
}
// ReactionOptions defines options for creating or deleting reactions
type ReactionOptions struct {
Type string
DoerID int64
IssueID int64
CommentID int64
}
// CreateReaction creates reaction for issue or comment.
func CreateReaction(ctx context.Context, opts *ReactionOptions) (*Reaction, error) {
if !setting.UI.ReactionsLookup.Contains(opts.Type) {
return nil, ErrForbiddenIssueReaction{opts.Type}
}
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return nil, err
}
defer committer.Close()
reaction, err := createReaction(ctx, opts)
if err != nil {
return reaction, err
}
if err := committer.Commit(); err != nil {
return nil, err
}
return reaction, nil
}
// DeleteReaction deletes reaction for issue or comment.
func DeleteReaction(ctx context.Context, opts *ReactionOptions) error {
reaction := &Reaction{
Type: opts.Type,
UserID: opts.DoerID,
IssueID: opts.IssueID,
CommentID: opts.CommentID,
}
sess := db.GetEngine(ctx).Where("original_author_id = 0")
if opts.CommentID == -1 {
reaction.CommentID = 0
sess.MustCols("comment_id")
}
_, err := sess.Delete(reaction)
return err
}
// DeleteIssueReaction deletes a reaction on issue.
func DeleteIssueReaction(ctx context.Context, doerID, issueID int64, content string) error {
return DeleteReaction(ctx, &ReactionOptions{
Type: content,
DoerID: doerID,
IssueID: issueID,
CommentID: -1,
})
}
// DeleteCommentReaction deletes a reaction on comment.
func DeleteCommentReaction(ctx context.Context, doerID, issueID, commentID int64, content string) error {
return DeleteReaction(ctx, &ReactionOptions{
Type: content,
DoerID: doerID,
IssueID: issueID,
CommentID: commentID,
})
}
// ReactionList represents list of reactions
type ReactionList []*Reaction
// HasUser check if user has reacted
func (list ReactionList) HasUser(userID int64) bool {
if userID == 0 {
return false
}
for _, reaction := range list {
if reaction.OriginalAuthor == "" && reaction.UserID == userID {
return true
}
}
return false
}
// GroupByType returns reactions grouped by type
func (list ReactionList) GroupByType() map[string]ReactionList {
reactions := make(map[string]ReactionList)
for _, reaction := range list {
reactions[reaction.Type] = append(reactions[reaction.Type], reaction)
}
return reactions
}
func (list ReactionList) getUserIDs() []int64 {
return container.FilterSlice(list, func(reaction *Reaction) (int64, bool) {
if reaction.OriginalAuthor != "" {
return 0, false
}
return reaction.UserID, true
})
}
func valuesUser(m map[int64]*user_model.User) []*user_model.User {
values := make([]*user_model.User, 0, len(m))
for _, v := range m {
values = append(values, v)
}
return values
}
// LoadUsers loads reactions' all users
func (list ReactionList) LoadUsers(ctx context.Context, repo *repo_model.Repository) ([]*user_model.User, error) {
if len(list) == 0 {
return nil, nil
}
userIDs := list.getUserIDs()
userMaps := make(map[int64]*user_model.User, len(userIDs))
err := db.GetEngine(ctx).
In("id", userIDs).
Find(&userMaps)
if err != nil {
return nil, fmt.Errorf("find user: %w", err)
}
for _, reaction := range list {
if reaction.OriginalAuthor != "" {
reaction.User = user_model.NewReplaceUser(fmt.Sprintf("%s(%s)", reaction.OriginalAuthor, repo.OriginalServiceType.Name()))
} else if user, ok := userMaps[reaction.UserID]; ok {
reaction.User = user
} else {
reaction.User = user_model.NewGhostUser()
}
}
return valuesUser(userMaps), nil
}
// GetFirstUsers returns first reacted user display names separated by comma
func (list ReactionList) GetFirstUsers() string {
var buffer bytes.Buffer
rem := setting.UI.ReactionMaxUserNum
for _, reaction := range list {
if buffer.Len() > 0 {
buffer.WriteString(", ")
}
buffer.WriteString(reaction.User.Name)
if rem--; rem == 0 {
break
}
}
return buffer.String()
}
// GetMoreUserCount returns count of not shown users in reaction tooltip
func (list ReactionList) GetMoreUserCount() int {
if len(list) <= setting.UI.ReactionMaxUserNum {
return 0
}
return len(list) - setting.UI.ReactionMaxUserNum
}