Full Mattermost server source with integrated Community Enterprise features. Includes vendor directory for offline/air-gapped builds. Structure: - enterprise-impl/: Enterprise feature implementations - enterprise-community/: Init files that register implementations - enterprise/: Bridge imports (community_imports.go) - vendor/: All dependencies for offline builds Build (online): go build ./cmd/mattermost Build (offline/air-gapped): go build -mod=vendor ./cmd/mattermost 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
3423 lines
99 KiB
Go
3423 lines
99 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package sqlstore
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"reflect"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/pkg/errors"
|
|
|
|
sq "github.com/mattermost/squirrel"
|
|
|
|
"github.com/mattermost/mattermost/server/public/model"
|
|
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
|
"github.com/mattermost/mattermost/server/public/shared/request"
|
|
"github.com/mattermost/mattermost/server/v8/channels/store"
|
|
"github.com/mattermost/mattermost/server/v8/channels/utils"
|
|
"github.com/mattermost/mattermost/server/v8/einterfaces"
|
|
)
|
|
|
|
// Regex to get quoted strings
|
|
var quotedStringsRegex = regexp.MustCompile(`("[^"]*")`)
|
|
var wildCardRegex = regexp.MustCompile(`\*($| )`)
|
|
|
|
type SqlPostStore struct {
|
|
*SqlStore
|
|
metrics einterfaces.MetricsInterface
|
|
maxPostSizeOnce sync.Once
|
|
maxPostSizeCached int
|
|
}
|
|
|
|
type postWithExtra struct {
|
|
ThreadReplyCount int64
|
|
IsFollowing *bool
|
|
ThreadParticipants model.StringArray
|
|
model.Post
|
|
}
|
|
|
|
func (s *SqlPostStore) ClearCaches() {
|
|
}
|
|
|
|
func postSliceColumnsWithTypes() []struct {
|
|
Name string
|
|
Type reflect.Kind
|
|
} {
|
|
return []struct {
|
|
Name string
|
|
Type reflect.Kind
|
|
}{
|
|
{"Id", reflect.String},
|
|
{"CreateAt", reflect.Int64},
|
|
{"UpdateAt", reflect.Int64},
|
|
{"EditAt", reflect.Int64},
|
|
{"DeleteAt", reflect.Int64},
|
|
{"IsPinned", reflect.Bool},
|
|
{"UserId", reflect.String},
|
|
{"ChannelId", reflect.String},
|
|
{"RootId", reflect.String},
|
|
{"OriginalId", reflect.String},
|
|
{"Message", reflect.String},
|
|
{"Type", reflect.String},
|
|
{"Props", reflect.Map},
|
|
{"Hashtags", reflect.String},
|
|
{"Filenames", reflect.Slice},
|
|
{"FileIds", reflect.Slice},
|
|
{"HasReactions", reflect.Bool},
|
|
{"RemoteId", reflect.String},
|
|
}
|
|
}
|
|
|
|
func postToSlice(post *model.Post) []any {
|
|
return []any{
|
|
post.Id,
|
|
post.CreateAt,
|
|
post.UpdateAt,
|
|
post.EditAt,
|
|
post.DeleteAt,
|
|
post.IsPinned,
|
|
post.UserId,
|
|
post.ChannelId,
|
|
post.RootId,
|
|
post.OriginalId,
|
|
post.Message,
|
|
post.Type,
|
|
model.StringInterfaceToJSON(post.Props),
|
|
post.Hashtags,
|
|
model.ArrayToJSON(post.Filenames),
|
|
model.ArrayToJSON(post.FileIds),
|
|
post.HasReactions,
|
|
post.RemoteId,
|
|
}
|
|
}
|
|
|
|
func postSliceColumns() []string {
|
|
colInfos := postSliceColumnsWithTypes()
|
|
cols := make([]string, len(colInfos))
|
|
for i, colInfo := range colInfos {
|
|
cols[i] = colInfo.Name
|
|
}
|
|
return cols
|
|
}
|
|
|
|
func postSliceColumnsWithName(name string) []string {
|
|
colInfos := postSliceColumnsWithTypes()
|
|
cols := make([]string, len(colInfos))
|
|
for i, colInfo := range colInfos {
|
|
cols[i] = name + "." + colInfo.Name
|
|
}
|
|
return cols
|
|
}
|
|
|
|
func postSliceCoalesceQuery() string {
|
|
colInfos := postSliceColumnsWithTypes()
|
|
cols := make([]string, len(colInfos))
|
|
for i, colInfo := range colInfos {
|
|
var defaultValue string
|
|
switch colInfo.Type {
|
|
case reflect.String:
|
|
defaultValue = "''"
|
|
case reflect.Int64:
|
|
defaultValue = "0"
|
|
case reflect.Bool:
|
|
defaultValue = "false"
|
|
case reflect.Map:
|
|
defaultValue = "'{}'"
|
|
case reflect.Slice:
|
|
defaultValue = "'[]'"
|
|
}
|
|
cols[i] = "COALESCE(Posts." + colInfo.Name + "," + defaultValue + ") AS " + colInfo.Name
|
|
}
|
|
return strings.Join(cols, ",")
|
|
}
|
|
|
|
func newSqlPostStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterface) store.PostStore {
|
|
return &SqlPostStore{
|
|
SqlStore: sqlStore,
|
|
metrics: metrics,
|
|
maxPostSizeCached: model.PostMessageMaxRunesV1,
|
|
}
|
|
}
|
|
|
|
func (s *SqlPostStore) SaveMultiple(rctx request.CTX, posts []*model.Post) ([]*model.Post, int, error) {
|
|
channelNewPosts := make(map[string]int)
|
|
channelNewRootPosts := make(map[string]int)
|
|
maxDateNewPosts := make(map[string]int64)
|
|
maxDateNewRootPosts := make(map[string]int64)
|
|
rootIds := make(map[string]int)
|
|
maxDateRootIds := make(map[string]int64)
|
|
for idx, post := range posts {
|
|
if post.Id != "" && !post.IsRemote() {
|
|
return nil, idx, store.NewErrInvalidInput("Post", "id", post.Id)
|
|
}
|
|
post.PreSave()
|
|
maxPostSize := s.GetMaxPostSize()
|
|
|
|
if err := post.IsValid(maxPostSize); err != nil {
|
|
return nil, idx, err
|
|
}
|
|
post.ValidateProps(rctx.Logger())
|
|
|
|
if currentChannelCount, ok := channelNewPosts[post.ChannelId]; !ok {
|
|
if post.IsJoinLeaveMessage() {
|
|
channelNewPosts[post.ChannelId] = 0
|
|
} else {
|
|
channelNewPosts[post.ChannelId] = 1
|
|
}
|
|
maxDateNewPosts[post.ChannelId] = post.CreateAt
|
|
} else {
|
|
if !post.IsJoinLeaveMessage() {
|
|
channelNewPosts[post.ChannelId] = currentChannelCount + 1
|
|
}
|
|
if post.CreateAt > maxDateNewPosts[post.ChannelId] {
|
|
maxDateNewPosts[post.ChannelId] = post.CreateAt
|
|
}
|
|
}
|
|
|
|
if post.RootId == "" {
|
|
if currentChannelCount, ok := channelNewRootPosts[post.ChannelId]; !ok {
|
|
if post.IsJoinLeaveMessage() {
|
|
channelNewRootPosts[post.ChannelId] = 0
|
|
} else {
|
|
channelNewRootPosts[post.ChannelId] = 1
|
|
}
|
|
maxDateNewRootPosts[post.ChannelId] = post.CreateAt
|
|
} else {
|
|
if !post.IsJoinLeaveMessage() {
|
|
channelNewRootPosts[post.ChannelId] = currentChannelCount + 1
|
|
}
|
|
if post.CreateAt > maxDateNewRootPosts[post.ChannelId] {
|
|
maxDateNewRootPosts[post.ChannelId] = post.CreateAt
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
if currentRootCount, ok := rootIds[post.RootId]; !ok {
|
|
rootIds[post.RootId] = 1
|
|
maxDateRootIds[post.RootId] = post.CreateAt
|
|
} else {
|
|
rootIds[post.RootId] = currentRootCount + 1
|
|
if post.CreateAt > maxDateRootIds[post.RootId] {
|
|
maxDateRootIds[post.RootId] = post.CreateAt
|
|
}
|
|
}
|
|
}
|
|
|
|
builder := s.getQueryBuilder().Insert("Posts").Columns(postSliceColumns()...)
|
|
for _, post := range posts {
|
|
builder = builder.Values(postToSlice(post)...)
|
|
}
|
|
query, args, err := builder.ToSql()
|
|
if err != nil {
|
|
return nil, -1, errors.Wrap(err, "post_tosql")
|
|
}
|
|
|
|
transaction, err := s.GetMaster().Beginx()
|
|
if err != nil {
|
|
return posts, -1, errors.Wrap(err, "begin_transaction")
|
|
}
|
|
defer finalizeTransactionX(transaction, &err)
|
|
|
|
if _, err = transaction.Exec(query, args...); err != nil {
|
|
return nil, -1, errors.Wrap(err, "failed to save Post")
|
|
}
|
|
|
|
if err = s.updateThreadsFromPosts(transaction, posts); err != nil {
|
|
return nil, -1, errors.Wrap(err, "update thread from posts failed")
|
|
}
|
|
|
|
if err = s.savePostsPriority(transaction, posts); err != nil {
|
|
return nil, -1, errors.Wrap(err, "failed to save PostPriority")
|
|
}
|
|
|
|
if err = s.savePostsPersistentNotifications(transaction, posts); err != nil {
|
|
return nil, -1, errors.Wrap(err, "failed to save posts persistent notifications")
|
|
}
|
|
|
|
if err = transaction.Commit(); err != nil {
|
|
// don't need to rollback here since the transaction is already closed
|
|
return posts, -1, errors.Wrap(err, "commit_transaction")
|
|
}
|
|
|
|
for channelId, count := range channelNewPosts {
|
|
countRoot := channelNewRootPosts[channelId]
|
|
|
|
if _, err = s.GetMaster().NamedExec(`UPDATE Channels
|
|
SET LastPostAt = GREATEST(:lastpostat, LastPostAt),
|
|
LastRootPostAt = GREATEST(:lastrootpostat, LastRootPostAt),
|
|
TotalMsgCount = TotalMsgCount + :count,
|
|
TotalMsgCountRoot = TotalMsgCountRoot + :countroot
|
|
WHERE Id = :channelid`, map[string]any{
|
|
"lastpostat": maxDateNewPosts[channelId],
|
|
"lastrootpostat": maxDateNewRootPosts[channelId],
|
|
"channelid": channelId,
|
|
"count": count,
|
|
"countroot": countRoot,
|
|
}); err != nil {
|
|
mlog.Warn("Error updating Channel LastPostAt.", mlog.Err(err))
|
|
}
|
|
}
|
|
|
|
for rootId := range rootIds {
|
|
if _, err = s.GetMaster().Exec("UPDATE Posts SET UpdateAt = ? WHERE Id = ?", maxDateRootIds[rootId], rootId); err != nil {
|
|
mlog.Warn("Error updating Post UpdateAt.", mlog.Err(err))
|
|
}
|
|
}
|
|
|
|
var unknownRepliesPosts []*model.Post
|
|
for _, post := range posts {
|
|
if post.RootId == "" {
|
|
count, ok := rootIds[post.Id]
|
|
if ok {
|
|
post.ReplyCount += int64(count)
|
|
}
|
|
} else {
|
|
unknownRepliesPosts = append(unknownRepliesPosts, post)
|
|
}
|
|
}
|
|
|
|
if len(unknownRepliesPosts) > 0 {
|
|
if err := s.populateReplyCount(unknownRepliesPosts); err != nil {
|
|
mlog.Warn("Unable to populate the reply count in some posts.", mlog.Err(err))
|
|
}
|
|
}
|
|
|
|
return posts, -1, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) Save(rctx request.CTX, post *model.Post) (*model.Post, error) {
|
|
posts, _, err := s.SaveMultiple(rctx, []*model.Post{post})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return posts[0], nil
|
|
}
|
|
|
|
func (s *SqlPostStore) populateReplyCount(posts []*model.Post) error {
|
|
rootIds := []string{}
|
|
for _, post := range posts {
|
|
rootIds = append(rootIds, post.RootId)
|
|
}
|
|
countList := []struct {
|
|
RootId string
|
|
Count int64
|
|
}{}
|
|
query := s.getQueryBuilder().
|
|
Select("RootId, COUNT(Id) AS Count").
|
|
From("Posts").
|
|
Where(sq.Eq{"RootId": rootIds}).
|
|
Where(sq.Eq{"Posts.DeleteAt": 0}).
|
|
GroupBy("RootId")
|
|
|
|
queryString, args, err := query.ToSql()
|
|
if err != nil {
|
|
return errors.Wrap(err, "post_tosql")
|
|
}
|
|
err = s.GetMaster().Select(&countList, queryString, args...)
|
|
if err != nil {
|
|
return errors.Wrap(err, "failed to count Posts")
|
|
}
|
|
|
|
counts := map[string]int64{}
|
|
for _, count := range countList {
|
|
counts[count.RootId] = count.Count
|
|
}
|
|
|
|
for _, post := range posts {
|
|
count, ok := counts[post.RootId]
|
|
if !ok {
|
|
post.ReplyCount = 0
|
|
}
|
|
post.ReplyCount = count
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *SqlPostStore) Update(rctx request.CTX, newPost *model.Post, oldPost *model.Post) (*model.Post, error) {
|
|
newPost.UpdateAt = model.GetMillis()
|
|
newPost.PreCommit()
|
|
|
|
oldPost.DeleteAt = newPost.UpdateAt
|
|
oldPost.UpdateAt = newPost.UpdateAt
|
|
oldPost.OriginalId = oldPost.Id
|
|
oldPost.Id = model.NewId()
|
|
oldPost.PreCommit()
|
|
|
|
maxPostSize := s.GetMaxPostSize()
|
|
|
|
if err := newPost.IsValid(maxPostSize); err != nil {
|
|
return nil, err
|
|
}
|
|
newPost.ValidateProps(rctx.Logger())
|
|
|
|
if _, err := s.GetMaster().NamedExec(`UPDATE Posts
|
|
SET CreateAt=:CreateAt,
|
|
UpdateAt=:UpdateAt,
|
|
EditAt=:EditAt,
|
|
DeleteAt=:DeleteAt,
|
|
IsPinned=:IsPinned,
|
|
UserId=:UserId,
|
|
ChannelId=:ChannelId,
|
|
RootId=:RootId,
|
|
OriginalId=:OriginalId,
|
|
Message=:Message,
|
|
Type=:Type,
|
|
Props=:Props,
|
|
Hashtags=:Hashtags,
|
|
Filenames=:Filenames,
|
|
FileIds=:FileIds,
|
|
HasReactions=:HasReactions,
|
|
RemoteId=:RemoteId
|
|
WHERE
|
|
Id=:Id
|
|
`, newPost); err != nil {
|
|
return nil, errors.Wrapf(err, "failed to update Post with id=%s", newPost.Id)
|
|
}
|
|
|
|
time := model.GetMillis()
|
|
if _, err := s.GetMaster().Exec("UPDATE Channels SET LastPostAt = ? WHERE Id = ? AND LastPostAt < ?", time, newPost.ChannelId, time); err != nil {
|
|
return nil, errors.Wrap(err, "failed to update lastpostat of channels")
|
|
}
|
|
|
|
if newPost.RootId != "" {
|
|
if _, err := s.GetMaster().Exec("UPDATE Posts SET UpdateAt = ? WHERE Id = ? AND UpdateAt < ?", time, newPost.RootId, time); err != nil {
|
|
return nil, errors.Wrap(err, "failed to update updateAt of posts")
|
|
}
|
|
}
|
|
|
|
// mark the old post as deleted
|
|
builder := s.getQueryBuilder().
|
|
Insert("Posts").
|
|
Columns(postSliceColumns()...).
|
|
Values(postToSlice(oldPost)...)
|
|
query, args, err := builder.ToSql()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "post_tosql")
|
|
}
|
|
_, err = s.GetMaster().Exec(query, args...)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to insert the old post")
|
|
}
|
|
|
|
return newPost, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) OverwriteMultiple(rctx request.CTX, posts []*model.Post) (_ []*model.Post, _ int, err error) {
|
|
updateAt := model.GetMillis()
|
|
maxPostSize := s.GetMaxPostSize()
|
|
for idx, post := range posts {
|
|
post.UpdateAt = updateAt
|
|
|
|
if appErr := post.IsValid(maxPostSize); appErr != nil {
|
|
return nil, idx, appErr
|
|
}
|
|
post.ValidateProps(rctx.Logger())
|
|
}
|
|
|
|
tx, err := s.GetMaster().Beginx()
|
|
if err != nil {
|
|
return nil, -1, errors.Wrap(err, "begin_transaction")
|
|
}
|
|
defer finalizeTransactionX(tx, &err)
|
|
|
|
for idx, post := range posts {
|
|
if _, err2 := tx.NamedExec(`UPDATE Posts
|
|
SET CreateAt=:CreateAt,
|
|
UpdateAt=:UpdateAt,
|
|
EditAt=:EditAt,
|
|
DeleteAt=:DeleteAt,
|
|
IsPinned=:IsPinned,
|
|
UserId=:UserId,
|
|
ChannelId=:ChannelId,
|
|
RootId=:RootId,
|
|
OriginalId=:OriginalId,
|
|
Message=:Message,
|
|
Type=:Type,
|
|
Props=:Props,
|
|
Hashtags=:Hashtags,
|
|
Filenames=:Filenames,
|
|
FileIds=:FileIds,
|
|
HasReactions=:HasReactions,
|
|
RemoteId=:RemoteId
|
|
WHERE
|
|
Id=:Id
|
|
`, post); err2 != nil {
|
|
return nil, idx, errors.Wrapf(err2, "failed to update Post with id=%s", post.Id)
|
|
}
|
|
if post.RootId != "" {
|
|
if _, err2 := tx.Exec("UPDATE Threads SET LastReplyAt = ? WHERE PostId = ?", updateAt, post.Id); err2 != nil {
|
|
return nil, idx, errors.Wrapf(err2, "failed to update Threads with postid=%s", post.Id)
|
|
}
|
|
}
|
|
}
|
|
err = tx.Commit()
|
|
if err != nil {
|
|
return nil, -1, errors.Wrap(err, "commit_transaction")
|
|
}
|
|
|
|
return posts, -1, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) Overwrite(rctx request.CTX, post *model.Post) (*model.Post, error) {
|
|
posts, _, err := s.OverwriteMultiple(rctx, []*model.Post{post})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return posts[0], nil
|
|
}
|
|
|
|
func (s *SqlPostStore) GetFlaggedPosts(userId string, offset int, limit int) (*model.PostList, error) {
|
|
return s.getFlaggedPosts(userId, "", "", offset, limit)
|
|
}
|
|
|
|
func (s *SqlPostStore) GetFlaggedPostsForTeam(userId, teamId string, offset int, limit int) (*model.PostList, error) {
|
|
return s.getFlaggedPosts(userId, "", teamId, offset, limit)
|
|
}
|
|
|
|
func (s *SqlPostStore) GetFlaggedPostsForChannel(userId, channelId string, offset int, limit int) (*model.PostList, error) {
|
|
return s.getFlaggedPosts(userId, channelId, "", offset, limit)
|
|
}
|
|
|
|
// TODO: convert to squirrel HW
|
|
func (s *SqlPostStore) getFlaggedPosts(userId, channelId, teamId string, offset int, limit int) (*model.PostList, error) {
|
|
pl := model.NewPostList()
|
|
|
|
posts := []*model.Post{}
|
|
query := `
|
|
SELECT
|
|
A.*, (SELECT count(*) FROM Posts WHERE Posts.RootId = (CASE WHEN A.RootId = '' THEN A.Id ELSE A.RootId END) AND Posts.DeleteAt = 0) as ReplyCount
|
|
FROM
|
|
(SELECT
|
|
*
|
|
FROM
|
|
Posts
|
|
WHERE
|
|
Id
|
|
IN
|
|
(
|
|
SELECT
|
|
Name
|
|
FROM
|
|
Preferences
|
|
WHERE
|
|
UserId = ?
|
|
AND Category = ?
|
|
)
|
|
CHANNEL_FILTER
|
|
AND Posts.DeleteAt = 0
|
|
) as A
|
|
INNER JOIN Channels as B
|
|
ON B.Id = A.ChannelId
|
|
WHERE
|
|
ChannelId IN (
|
|
SELECT
|
|
ChannelId
|
|
FROM
|
|
ChannelMembers
|
|
WHERE UserId = ?
|
|
)
|
|
TEAM_FILTER
|
|
ORDER BY CreateAt DESC
|
|
LIMIT ? OFFSET ?`
|
|
|
|
queryParams := []any{userId, model.PreferenceCategoryFlaggedPost}
|
|
|
|
var channelClause, teamClause string
|
|
channelClause, queryParams = s.buildFlaggedPostChannelFilterClause(channelId, queryParams)
|
|
query = strings.Replace(query, "CHANNEL_FILTER", channelClause, 1)
|
|
|
|
queryParams = append(queryParams, userId)
|
|
|
|
teamClause, queryParams = s.buildFlaggedPostTeamFilterClause(teamId, queryParams)
|
|
query = strings.Replace(query, "TEAM_FILTER", teamClause, 1)
|
|
|
|
queryParams = append(queryParams, limit, offset)
|
|
|
|
if err := s.GetReplica().Select(&posts, query, queryParams...); err != nil {
|
|
return nil, errors.Wrap(err, "failed to find Posts")
|
|
}
|
|
|
|
for _, post := range posts {
|
|
pl.AddPost(post)
|
|
pl.AddOrder(post.Id)
|
|
}
|
|
|
|
return pl, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) buildFlaggedPostTeamFilterClause(teamId string, queryParams []any) (string, []any) {
|
|
if teamId == "" {
|
|
return "", queryParams
|
|
}
|
|
|
|
return "AND B.TeamId = ? OR B.TeamId = ''", append(queryParams, teamId)
|
|
}
|
|
|
|
func (s *SqlPostStore) buildFlaggedPostChannelFilterClause(channelId string, queryParams []any) (string, []any) {
|
|
if channelId == "" {
|
|
return "", queryParams
|
|
}
|
|
|
|
return "AND ChannelId = ?", append(queryParams, channelId)
|
|
}
|
|
|
|
func (s *SqlPostStore) getPostWithCollapsedThreads(rctx request.CTX, id, userID string, opts model.GetPostsOptions, sanitizeOptions map[string]bool) (*model.PostList, error) {
|
|
if id == "" {
|
|
return nil, store.NewErrInvalidInput("Post", "id", id)
|
|
}
|
|
|
|
var columns []string
|
|
for _, c := range postSliceColumns() {
|
|
columns = append(columns, "Posts."+c)
|
|
}
|
|
columns = append(columns,
|
|
"COALESCE(Threads.ReplyCount, 0) as ThreadReplyCount",
|
|
"COALESCE(Threads.LastReplyAt, 0) as LastReplyAt",
|
|
"COALESCE(Threads.Participants, '[]') as ThreadParticipants",
|
|
"ThreadMemberships.Following as IsFollowing",
|
|
)
|
|
var post postWithExtra
|
|
|
|
postFetchQuery, args, err := s.getQueryBuilder().
|
|
Select(columns...).
|
|
From("Posts").
|
|
LeftJoin("Threads ON Threads.PostId = Id").
|
|
LeftJoin("ThreadMemberships ON ThreadMemberships.PostId = Id AND ThreadMemberships.UserId = ?", userID).
|
|
Where(sq.Eq{"Posts.DeleteAt": 0}).
|
|
Where(sq.Eq{"Posts.Id": id}).ToSql()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "getPostWithCollapsedThreads_ToSql2")
|
|
}
|
|
|
|
err = s.GetReplica().Get(&post, postFetchQuery, args...)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, store.NewErrNotFound("Post", id)
|
|
}
|
|
|
|
return nil, errors.Wrapf(err, "failed to get Post with id=%s", id)
|
|
}
|
|
|
|
posts := []*model.Post{}
|
|
query := s.getQueryBuilder().
|
|
Select("*").
|
|
From("Posts").
|
|
Where(sq.Eq{
|
|
"Posts.RootId": id,
|
|
"Posts.DeleteAt": 0,
|
|
})
|
|
|
|
var sort string
|
|
if opts.Direction != "" {
|
|
if opts.Direction == "up" {
|
|
sort = "DESC"
|
|
} else if opts.Direction == "down" {
|
|
sort = "ASC"
|
|
}
|
|
}
|
|
if sort != "" {
|
|
if opts.UpdatesOnly {
|
|
query = query.OrderBy("UpdateAt " + sort + ", Id " + sort)
|
|
} else {
|
|
query = query.OrderBy("CreateAt " + sort + ", Id " + sort)
|
|
}
|
|
}
|
|
|
|
if opts.FromCreateAt != 0 {
|
|
var direction sq.Sqlizer
|
|
var pagination sq.Sqlizer
|
|
|
|
if opts.Direction == "down" {
|
|
direction = sq.Gt{"Posts.CreateAt": opts.FromCreateAt}
|
|
pagination = sq.Gt{"Posts.Id": opts.FromPost}
|
|
} else {
|
|
direction = sq.Lt{"Posts.CreateAt": opts.FromCreateAt}
|
|
pagination = sq.Lt{"Posts.Id": opts.FromPost}
|
|
}
|
|
|
|
if opts.FromPost != "" {
|
|
query = query.Where(sq.Or{
|
|
direction,
|
|
sq.And{
|
|
sq.Eq{"Posts.CreateAt": opts.FromCreateAt},
|
|
pagination,
|
|
},
|
|
})
|
|
} else {
|
|
query = query.Where(direction)
|
|
}
|
|
}
|
|
|
|
if opts.FromUpdateAt != 0 && opts.Direction == "down" {
|
|
direction := sq.Gt{"Posts.UpdateAt": opts.FromUpdateAt}
|
|
if opts.FromPost != "" {
|
|
query = query.Where(sq.Or{
|
|
direction,
|
|
sq.And{
|
|
sq.Eq{"Posts.UpdateAt": opts.FromUpdateAt},
|
|
sq.Gt{"Posts.Id": opts.FromPost},
|
|
},
|
|
})
|
|
} else {
|
|
query = query.Where(direction)
|
|
}
|
|
}
|
|
|
|
if opts.PerPage != 0 {
|
|
query = query.Limit(uint64(opts.PerPage + 1))
|
|
}
|
|
|
|
sql, args, err := query.ToSql()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "getPostWithCollapsedThreads_Tosql2")
|
|
}
|
|
err = s.GetReplica().Select(&posts, sql, args...)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "failed to find Posts for thread %s", id)
|
|
}
|
|
|
|
var hasNext bool
|
|
if opts.PerPage != 0 {
|
|
if len(posts) == opts.PerPage+1 {
|
|
hasNext = true
|
|
}
|
|
}
|
|
if hasNext {
|
|
// Shave off the last item.
|
|
posts = posts[:len(posts)-1]
|
|
}
|
|
|
|
list, err := s.prepareThreadedResponse(rctx, []*postWithExtra{&post}, opts.CollapsedThreadsExtended, false, sanitizeOptions)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, p := range posts {
|
|
list.AddPost(p)
|
|
list.AddOrder(p.Id)
|
|
}
|
|
list.HasNext = &hasNext
|
|
|
|
return list, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) Get(rctx request.CTX, id string, opts model.GetPostsOptions, userID string, sanitizeOptions map[string]bool) (*model.PostList, error) {
|
|
if opts.CollapsedThreads {
|
|
return s.getPostWithCollapsedThreads(rctx, id, userID, opts, sanitizeOptions)
|
|
}
|
|
pl := model.NewPostList()
|
|
|
|
if id == "" {
|
|
return nil, store.NewErrInvalidInput("Post", "id", id)
|
|
}
|
|
var post model.Post
|
|
postFetchQuery := "SELECT p.*, (SELECT count(*) FROM Posts WHERE Posts.RootId = (CASE WHEN p.RootId = '' THEN p.Id ELSE p.RootId END) AND Posts.DeleteAt = 0) as ReplyCount FROM Posts p WHERE p.Id = ? AND p.DeleteAt = 0"
|
|
err := s.DBXFromContext(rctx.Context()).Get(&post, postFetchQuery, id)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, store.NewErrNotFound("Post", id)
|
|
}
|
|
|
|
return nil, errors.Wrapf(err, "failed to get Post with id=%s", id)
|
|
}
|
|
pl.AddPost(&post)
|
|
pl.AddOrder(id)
|
|
if !opts.SkipFetchThreads {
|
|
rootId := post.RootId
|
|
|
|
if rootId == "" {
|
|
rootId = post.Id
|
|
}
|
|
|
|
if rootId == "" {
|
|
return nil, errors.Wrapf(err, "invalid rootId with value=%s", rootId)
|
|
}
|
|
|
|
var query sq.SelectBuilder
|
|
query = s.getQueryBuilder().
|
|
Select("p.*, replycount.num as ReplyCount").
|
|
PrefixExpr(s.getQueryBuilder().
|
|
Select().
|
|
Prefix("WITH replycount as (").
|
|
Columns("count(*) as num").
|
|
From("posts").
|
|
Where(sq.And{
|
|
sq.Eq{"RootId": rootId},
|
|
sq.Eq{"DeleteAt": 0},
|
|
}).Suffix(")"),
|
|
).
|
|
From("Posts p, replycount").
|
|
Where(sq.And{
|
|
sq.Or{
|
|
sq.Eq{"p.Id": rootId},
|
|
sq.Eq{"p.RootId": rootId},
|
|
},
|
|
sq.Eq{"p.DeleteAt": 0},
|
|
})
|
|
|
|
var sort string
|
|
if opts.Direction != "" {
|
|
if opts.Direction == "up" {
|
|
sort = "DESC"
|
|
} else if opts.Direction == "down" {
|
|
sort = "ASC"
|
|
}
|
|
}
|
|
if sort != "" {
|
|
if opts.UpdatesOnly {
|
|
query = query.OrderBy("UpdateAt " + sort + ", Id " + sort)
|
|
} else {
|
|
query = query.OrderBy("CreateAt " + sort + ", Id " + sort)
|
|
}
|
|
}
|
|
|
|
if opts.FromCreateAt != 0 {
|
|
if opts.Direction == "down" {
|
|
direction := sq.Gt{"p.CreateAt": opts.FromCreateAt}
|
|
if opts.FromPost != "" {
|
|
query = query.Where(sq.Or{
|
|
direction,
|
|
sq.And{
|
|
sq.Eq{"p.CreateAt": opts.FromCreateAt},
|
|
sq.Gt{"p.Id": opts.FromPost},
|
|
},
|
|
})
|
|
} else {
|
|
query = query.Where(direction)
|
|
}
|
|
} else {
|
|
direction := sq.Lt{"p.CreateAt": opts.FromCreateAt}
|
|
if opts.FromPost != "" {
|
|
query = query.Where(sq.Or{
|
|
direction,
|
|
sq.And{
|
|
sq.Eq{"p.CreateAt": opts.FromCreateAt},
|
|
sq.Lt{"p.Id": opts.FromPost},
|
|
},
|
|
})
|
|
} else {
|
|
query = query.Where(direction)
|
|
}
|
|
}
|
|
}
|
|
|
|
if opts.FromUpdateAt != 0 {
|
|
if opts.Direction == "down" {
|
|
direction := sq.Gt{"p.UpdateAt": opts.FromUpdateAt}
|
|
if opts.FromPost != "" {
|
|
query = query.Where(sq.Or{
|
|
direction,
|
|
sq.And{
|
|
sq.Eq{"p.UpdateAt": opts.FromUpdateAt},
|
|
sq.Gt{"p.Id": opts.FromPost},
|
|
},
|
|
})
|
|
} else {
|
|
query = query.Where(direction)
|
|
}
|
|
} else {
|
|
direction := sq.Lt{"p.UpdateAt": opts.FromUpdateAt}
|
|
if opts.FromPost != "" {
|
|
query = query.Where(sq.Or{
|
|
direction,
|
|
sq.And{
|
|
sq.Eq{"p.UpdateAt": opts.FromUpdateAt},
|
|
sq.Lt{"p.Id": opts.FromPost},
|
|
},
|
|
})
|
|
} else {
|
|
query = query.Where(direction)
|
|
}
|
|
}
|
|
}
|
|
|
|
if opts.PerPage != 0 {
|
|
query = query.Limit(uint64(opts.PerPage + 1))
|
|
}
|
|
|
|
sql, args, err := query.ToSql()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "Get_Tosql")
|
|
}
|
|
|
|
posts := []*model.Post{}
|
|
err = s.GetReplica().Select(&posts, sql, args...)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to find Posts")
|
|
}
|
|
|
|
var hasNext bool
|
|
if opts.PerPage != 0 {
|
|
if len(posts) == opts.PerPage+1 {
|
|
hasNext = true
|
|
}
|
|
}
|
|
if hasNext {
|
|
// Shave off the last item
|
|
posts = posts[:len(posts)-1]
|
|
}
|
|
|
|
for _, p := range posts {
|
|
if p.Id == id {
|
|
// Based on the conditions above such as sq.Or{ sq.Eq{"p.Id": rootId}, sq.Eq{"p.RootId": rootId}, }
|
|
// posts may contain the "id" post which has already been fetched and added in the "pl"
|
|
// So, skip the "id" to avoid duplicate entry of the post
|
|
continue
|
|
}
|
|
|
|
pl.AddPost(p)
|
|
pl.AddOrder(p.Id)
|
|
}
|
|
pl.HasNext = &hasNext
|
|
}
|
|
return pl, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) GetSingle(rctx request.CTX, id string, inclDeleted bool) (*model.Post, error) {
|
|
query := s.getQueryBuilder().
|
|
Select("p.*").
|
|
From("Posts p").
|
|
Where(sq.Eq{"p.Id": id})
|
|
|
|
replyCountSubQuery := s.getQueryBuilder().
|
|
Select("COUNT(*)").
|
|
From("Posts").
|
|
Where(sq.Expr("Posts.RootId = (CASE WHEN p.RootId = '' THEN p.Id ELSE p.RootId END) AND Posts.DeleteAt = 0"))
|
|
|
|
if !inclDeleted {
|
|
query = query.Where(sq.Eq{"p.DeleteAt": 0})
|
|
}
|
|
query = query.Column(sq.Alias(replyCountSubQuery, "ReplyCount"))
|
|
|
|
queryString, args, err := query.ToSql()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "getsingleincldeleted_tosql")
|
|
}
|
|
|
|
var post model.Post
|
|
err = s.DBXFromContext(rctx.Context()).Get(&post, queryString, args...)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, store.NewErrNotFound("Post", id)
|
|
}
|
|
|
|
return nil, errors.Wrapf(err, "failed to get Post with id=%s", id)
|
|
}
|
|
return &post, nil
|
|
}
|
|
|
|
type etagPosts struct {
|
|
Id string
|
|
UpdateAt int64
|
|
}
|
|
|
|
//nolint:unparam
|
|
func (s *SqlPostStore) InvalidateLastPostTimeCache(channelId string) {
|
|
}
|
|
|
|
//nolint:unparam
|
|
func (s *SqlPostStore) GetEtag(channelId string, allowFromCache, collapsedThreads bool) string {
|
|
q := s.getQueryBuilder().Select("Id", "UpdateAt").From("Posts").Where(sq.Eq{"ChannelId": channelId}).OrderBy("UpdateAt DESC").Limit(1)
|
|
if collapsedThreads {
|
|
q.Where(sq.Eq{"RootId": ""})
|
|
}
|
|
sql, args := q.MustSql()
|
|
|
|
var et etagPosts
|
|
err := s.GetReplica().Get(&et, sql, args...)
|
|
var result string
|
|
if err != nil {
|
|
result = fmt.Sprintf("%v.%v", model.CurrentVersion, model.GetMillis())
|
|
} else {
|
|
result = fmt.Sprintf("%v.%v", model.CurrentVersion, et.UpdateAt)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// Soft deletes a post
|
|
// and cleans up the thread if it's a comment
|
|
func (s *SqlPostStore) Delete(rctx request.CTX, postID string, time int64, deleteByID string) (err error) {
|
|
transaction, err := s.GetMaster().Beginx()
|
|
if err != nil {
|
|
return errors.Wrap(err, "begin_transaction")
|
|
}
|
|
defer finalizeTransactionX(transaction, &err)
|
|
|
|
id := postIds{}
|
|
// TODO: change this to later delete thread directly from postID
|
|
err = transaction.Get(&id, "SELECT RootId, UserId FROM Posts WHERE Id = ?", postID)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return store.NewErrNotFound("Post", postID)
|
|
}
|
|
|
|
return errors.Wrapf(err, "failed to delete Post with id=%s", postID)
|
|
}
|
|
|
|
_, err = transaction.Exec(`UPDATE Posts
|
|
SET DeleteAt = $1,
|
|
UpdateAt = $1,
|
|
Props = jsonb_set(Props, $2, $3)
|
|
WHERE Id = $4 OR RootId = $4`, time, jsonKeyPath(model.PostPropsDeleteBy), jsonStringVal(deleteByID), postID)
|
|
|
|
if err != nil {
|
|
return errors.Wrap(err, "failed to update Posts")
|
|
}
|
|
|
|
if id.RootId == "" {
|
|
err = s.deleteThread(transaction, postID, time)
|
|
} else {
|
|
err = s.updateThreadAfterReplyDeletion(transaction, id.RootId, id.UserId)
|
|
updatePostQuery := s.getQueryBuilder().
|
|
Update("Posts").
|
|
Set("UpdateAt", time).
|
|
Where(sq.Eq{"Id": id.RootId})
|
|
if _, err = transaction.ExecBuilder(updatePostQuery); err != nil {
|
|
mlog.Warn("Error updating Post UpdateAt.", mlog.Err(err))
|
|
}
|
|
}
|
|
|
|
if err != nil {
|
|
return errors.Wrapf(err, "failed to cleanup Thread with postid=%s", id.RootId)
|
|
}
|
|
|
|
if err = transaction.Commit(); err != nil {
|
|
return errors.Wrap(err, "commit_transaction")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *SqlPostStore) PermanentDelete(rctx request.CTX, postID string) (err error) {
|
|
return s.permanentDelete([]string{postID})
|
|
}
|
|
|
|
func (s *SqlPostStore) permanentDelete(postIds []string) (err error) {
|
|
transaction, err := s.GetMaster().Beginx()
|
|
if err != nil {
|
|
return errors.Wrap(err, "begin_transaction")
|
|
}
|
|
defer finalizeTransactionX(transaction, &err)
|
|
|
|
if err = s.permanentDeleteThreads(transaction, postIds); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = s.permanentDeleteReactions(transaction, postIds); err != nil {
|
|
return err
|
|
}
|
|
|
|
query := s.getQueryBuilder().
|
|
Delete("Posts").
|
|
Where(
|
|
sq.Or{
|
|
sq.Eq{"Id": postIds},
|
|
sq.Eq{"RootId": postIds},
|
|
},
|
|
)
|
|
if _, err = transaction.ExecBuilder(query); err != nil {
|
|
return errors.Wrap(err, "failed to delete Posts")
|
|
}
|
|
|
|
if err = transaction.Commit(); err != nil {
|
|
return errors.Wrap(err, "commit_transaction")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type postIds struct {
|
|
Id string
|
|
RootId string
|
|
UserId string
|
|
}
|
|
|
|
func (s *SqlPostStore) permanentDeleteAllCommentByUser(userId string) (err error) {
|
|
results := []postIds{}
|
|
transaction, err := s.GetMaster().Beginx()
|
|
if err != nil {
|
|
return errors.Wrap(err, "begin_transaction")
|
|
}
|
|
defer finalizeTransactionX(transaction, &err)
|
|
|
|
err = transaction.Select(&results, "Select Id, RootId FROM Posts WHERE UserId = ? AND RootId != ''", userId)
|
|
if err != nil {
|
|
return errors.Wrapf(err, "failed to fetch Posts with userId=%s", userId)
|
|
}
|
|
|
|
_, err = transaction.Exec("DELETE FROM Posts WHERE UserId = ? AND RootId != ''", userId)
|
|
if err != nil {
|
|
return errors.Wrapf(err, "failed to delete Posts with userId=%s", userId)
|
|
}
|
|
|
|
postIds := []string{}
|
|
for _, ids := range results {
|
|
if err = s.updateThreadAfterReplyDeletion(transaction, ids.RootId, userId); err != nil {
|
|
return err
|
|
}
|
|
postIds = append(postIds, ids.Id)
|
|
}
|
|
|
|
// Delete all the reactions on the comments
|
|
if err = s.permanentDeleteReactions(transaction, postIds); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = transaction.Commit(); err != nil {
|
|
return errors.Wrap(err, "commit_transaction")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Permanently deletes all comments by user,
|
|
// cleans up threads (removes said user from participants and decreases reply count),
|
|
// permanent delete all root posts by user,
|
|
// and delete threads and thread memberships for those root posts
|
|
func (s *SqlPostStore) PermanentDeleteByUser(rctx request.CTX, userId string) error {
|
|
// First attempt to delete all the comments for a user
|
|
if err := s.permanentDeleteAllCommentByUser(userId); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Now attempt to delete all the root posts for a user. This will also
|
|
// delete all the comments for each post
|
|
const maxLoops = 10
|
|
count := 0
|
|
for {
|
|
var ids []string
|
|
err := s.GetMaster().Select(&ids, "SELECT Id FROM Posts WHERE UserId = ? LIMIT 1000", userId)
|
|
if err != nil {
|
|
return errors.Wrapf(err, "failed to find Posts with userId=%s", userId)
|
|
}
|
|
|
|
if len(ids) == 0 {
|
|
break
|
|
}
|
|
|
|
if err = s.permanentDelete(ids); err != nil {
|
|
return err
|
|
}
|
|
|
|
// This is a fail safe, give up if more than 10k messages
|
|
count++
|
|
if count >= maxLoops {
|
|
return store.NewErrLimitExceeded("permanently deleting posts for user", maxLoops*1000, "userId="+userId)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Permanent deletes all channel root posts and comments,
|
|
// deletes all threads and thread memberships
|
|
// deletes all reactions
|
|
// no thread comment cleanup needed, since we are deleting threads and thread memberships
|
|
func (s *SqlPostStore) PermanentDeleteByChannel(rctx request.CTX, channelId string) (err error) {
|
|
transaction, err := s.GetMaster().Beginx()
|
|
if err != nil {
|
|
return errors.Wrap(err, "begin_transaction")
|
|
}
|
|
defer finalizeTransactionX(transaction, &err)
|
|
|
|
id := ""
|
|
for {
|
|
ids := []string{}
|
|
err = transaction.Select(&ids, "SELECT Id FROM Posts WHERE ChannelId = ? AND Id > ? ORDER BY Id ASC LIMIT 500", channelId, id)
|
|
if err != nil {
|
|
return errors.Wrapf(err, "failed to fetch Posts with channelId=%s", channelId)
|
|
}
|
|
|
|
if len(ids) == 0 {
|
|
break
|
|
}
|
|
|
|
id = ids[len(ids)-1]
|
|
|
|
if err = s.permanentDeleteThreads(transaction, ids); err != nil {
|
|
return err
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
if err = s.permanentDeleteReactions(transaction, ids); err != nil {
|
|
return err
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
query := s.getQueryBuilder().
|
|
Delete("Posts").
|
|
Where(
|
|
sq.Eq{"Id": ids},
|
|
)
|
|
if _, err = transaction.ExecBuilder(query); err != nil {
|
|
return errors.Wrap(err, "failed to delete Posts")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
|
|
if err = transaction.Commit(); err != nil {
|
|
return errors.Wrap(err, "commit_transaction")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *SqlPostStore) prepareThreadedResponse(rctx request.CTX, posts []*postWithExtra, extended, reversed bool, sanitizeOptions map[string]bool) (*model.PostList, error) {
|
|
list := model.NewPostList()
|
|
var userIds []string
|
|
userIdMap := map[string]bool{}
|
|
for _, thread := range posts {
|
|
for _, participantId := range thread.ThreadParticipants {
|
|
if _, ok := userIdMap[participantId]; !ok {
|
|
userIdMap[participantId] = true
|
|
userIds = append(userIds, participantId)
|
|
}
|
|
}
|
|
}
|
|
// usersMap is the global profile map of all participants from all threads.
|
|
usersMap := make(map[string]*model.User, len(userIds))
|
|
if extended {
|
|
users, err := s.User().GetProfileByIds(rctx, userIds, &store.UserGetByIdsOpts{}, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, user := range users {
|
|
user.SanitizeProfile(sanitizeOptions, false)
|
|
usersMap[user.Id] = user
|
|
}
|
|
} else {
|
|
for _, userId := range userIds {
|
|
usersMap[userId] = &model.User{Id: userId}
|
|
}
|
|
}
|
|
|
|
processPost := func(p *postWithExtra) error {
|
|
p.Post.ReplyCount = p.ThreadReplyCount
|
|
if p.IsFollowing != nil {
|
|
p.Post.IsFollowing = model.NewPointer(*p.IsFollowing)
|
|
}
|
|
for _, userID := range p.ThreadParticipants {
|
|
participant, ok := usersMap[userID]
|
|
if !ok {
|
|
return errors.New("cannot find thread participant with id=" + userID)
|
|
}
|
|
p.Post.Participants = append(p.Post.Participants, participant)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
l := len(posts)
|
|
for i := range posts {
|
|
idx := i
|
|
// We need to flip the order if we selected backwards
|
|
|
|
if reversed {
|
|
idx = l - i - 1
|
|
}
|
|
if err := processPost(posts[idx]); err != nil {
|
|
return nil, err
|
|
}
|
|
post := &posts[idx].Post
|
|
list.AddPost(post)
|
|
list.AddOrder(posts[idx].Id)
|
|
}
|
|
|
|
return list, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) getPostsCollapsedThreads(rctx request.CTX, options model.GetPostsOptions, sanitizeOptions map[string]bool) (*model.PostList, error) {
|
|
var columns []string
|
|
for _, c := range postSliceColumns() {
|
|
columns = append(columns, "Posts."+c)
|
|
}
|
|
columns = append(columns,
|
|
"COALESCE(Threads.ReplyCount, 0) as ThreadReplyCount",
|
|
"COALESCE(Threads.LastReplyAt, 0) as LastReplyAt",
|
|
"COALESCE(Threads.Participants, '[]') as ThreadParticipants",
|
|
"ThreadMemberships.Following as IsFollowing",
|
|
)
|
|
var posts []*postWithExtra
|
|
offset := options.PerPage * options.Page
|
|
|
|
postFetchQuery, args, _ := s.getQueryBuilder().
|
|
Select(columns...).
|
|
From("Posts").
|
|
LeftJoin("Threads ON Threads.PostId = Posts.Id").
|
|
LeftJoin("ThreadMemberships ON ThreadMemberships.PostId = Posts.Id AND ThreadMemberships.UserId = ?", options.UserId).
|
|
Where(sq.Eq{"Posts.DeleteAt": 0}).
|
|
Where(sq.Eq{"Posts.ChannelId": options.ChannelId}).
|
|
Where(sq.Eq{"Posts.RootId": ""}).
|
|
Limit(uint64(options.PerPage)).
|
|
Offset(uint64(offset)).
|
|
OrderBy("Posts.CreateAt DESC").ToSql()
|
|
|
|
err := s.GetReplica().Select(&posts, postFetchQuery, args...)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "failed to find Posts with channelId=%s", options.ChannelId)
|
|
}
|
|
|
|
return s.prepareThreadedResponse(rctx, posts, options.CollapsedThreadsExtended, false, sanitizeOptions)
|
|
}
|
|
|
|
func (s *SqlPostStore) GetPosts(rctx request.CTX, options model.GetPostsOptions, _ bool, sanitizeOptions map[string]bool) (*model.PostList, error) {
|
|
if options.PerPage > 1000 {
|
|
return nil, store.NewErrInvalidInput("Post", "<options.PerPage>", options.PerPage)
|
|
}
|
|
if options.CollapsedThreads {
|
|
return s.getPostsCollapsedThreads(rctx, options, sanitizeOptions)
|
|
}
|
|
offset := options.PerPage * options.Page
|
|
|
|
rpc := make(chan store.StoreResult[[]*model.Post], 1)
|
|
go func() {
|
|
posts, err := s.getRootPosts(options.ChannelId, offset, options.PerPage, options.SkipFetchThreads, options.IncludeDeleted)
|
|
rpc <- store.StoreResult[[]*model.Post]{Data: posts, NErr: err}
|
|
close(rpc)
|
|
}()
|
|
cpc := make(chan store.StoreResult[[]*model.Post], 1)
|
|
go func() {
|
|
posts, err := s.getParentsPosts(options.ChannelId, offset, options.PerPage, options.SkipFetchThreads, options.IncludeDeleted)
|
|
cpc <- store.StoreResult[[]*model.Post]{Data: posts, NErr: err}
|
|
close(cpc)
|
|
}()
|
|
|
|
list := model.NewPostList()
|
|
|
|
rpr := <-rpc
|
|
if rpr.NErr != nil {
|
|
return nil, rpr.NErr
|
|
}
|
|
|
|
cpr := <-cpc
|
|
if cpr.NErr != nil {
|
|
return nil, cpr.NErr
|
|
}
|
|
|
|
posts := rpr.Data
|
|
parents := cpr.Data
|
|
|
|
for _, p := range posts {
|
|
list.AddPost(p)
|
|
list.AddOrder(p.Id)
|
|
}
|
|
|
|
for _, p := range parents {
|
|
list.AddPost(p)
|
|
}
|
|
|
|
list.MakeNonNil()
|
|
|
|
return list, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) getPostsSinceCollapsedThreads(rctx request.CTX, options model.GetPostsSinceOptions, sanitizeOptions map[string]bool) (*model.PostList, error) {
|
|
var columns []string
|
|
for _, c := range postSliceColumns() {
|
|
columns = append(columns, "Posts."+c)
|
|
}
|
|
columns = append(columns,
|
|
"COALESCE(Threads.ReplyCount, 0) as ThreadReplyCount",
|
|
"COALESCE(Threads.LastReplyAt, 0) as LastReplyAt",
|
|
"COALESCE(Threads.Participants, '[]') as ThreadParticipants",
|
|
"ThreadMemberships.Following as IsFollowing",
|
|
)
|
|
var posts []*postWithExtra
|
|
|
|
postFetchQuery, args, err := s.getQueryBuilder().
|
|
Select(columns...).
|
|
From("Posts").
|
|
LeftJoin("Threads ON Threads.PostId = Posts.Id").
|
|
LeftJoin("ThreadMemberships ON ThreadMemberships.PostId = Posts.Id AND ThreadMemberships.UserId = ?", options.UserId).
|
|
Where(sq.Eq{"Posts.ChannelId": options.ChannelId}).
|
|
Where(sq.Gt{"Posts.UpdateAt": options.Time}).
|
|
Where(sq.Eq{"Posts.RootId": ""}).
|
|
OrderBy("Posts.CreateAt DESC").
|
|
Limit(1000).
|
|
ToSql()
|
|
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "getPostsSinceCollapsedThreads_ToSql")
|
|
}
|
|
|
|
err = s.GetReplica().Select(&posts, postFetchQuery, args...)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "failed to find Posts with channelId=%s", options.ChannelId)
|
|
}
|
|
return s.prepareThreadedResponse(rctx, posts, options.CollapsedThreadsExtended, false, sanitizeOptions)
|
|
}
|
|
|
|
//nolint:unparam
|
|
func (s *SqlPostStore) GetPostsSince(rctx request.CTX, options model.GetPostsSinceOptions, allowFromCache bool, sanitizeOptions map[string]bool) (*model.PostList, error) {
|
|
if options.CollapsedThreads {
|
|
return s.getPostsSinceCollapsedThreads(rctx, options, sanitizeOptions)
|
|
}
|
|
|
|
posts := []*model.Post{}
|
|
|
|
order := "DESC"
|
|
if options.SortAscending {
|
|
order = "ASC"
|
|
}
|
|
|
|
replyCountQuery1 := ""
|
|
replyCountQuery2 := ""
|
|
if options.SkipFetchThreads {
|
|
replyCountQuery1 = `, (SELECT COUNT(*) FROM Posts WHERE Posts.RootId = (CASE WHEN p1.RootId = '' THEN p1.Id ELSE p1.RootId END) AND Posts.DeleteAt = 0) as ReplyCount`
|
|
replyCountQuery2 = `, (SELECT COUNT(*) FROM Posts WHERE Posts.RootId = (CASE WHEN cte.RootId = '' THEN cte.Id ELSE cte.RootId END) AND Posts.DeleteAt = 0) as ReplyCount`
|
|
}
|
|
var query string
|
|
var params []any
|
|
|
|
query = `WITH cte AS (SELECT
|
|
*
|
|
FROM
|
|
Posts
|
|
WHERE
|
|
UpdateAt > ? AND ChannelId = ?
|
|
LIMIT 1000)
|
|
(SELECT *` + replyCountQuery2 + ` FROM cte)
|
|
UNION
|
|
(SELECT *` + replyCountQuery1 + ` FROM Posts p1 WHERE id in (SELECT rootid FROM cte))
|
|
ORDER BY CreateAt ` + order
|
|
|
|
params = []any{options.Time, options.ChannelId}
|
|
err := s.GetReplica().Select(&posts, query, params...)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "failed to find Posts with channelId=%s", options.ChannelId)
|
|
}
|
|
|
|
list := model.NewPostList()
|
|
|
|
for _, p := range posts {
|
|
list.AddPost(p)
|
|
if p.UpdateAt > options.Time {
|
|
list.AddOrder(p.Id)
|
|
}
|
|
}
|
|
|
|
return list, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) HasAutoResponsePostByUserSince(options model.GetPostsSinceOptions, userId string) (bool, error) {
|
|
query := `
|
|
SELECT EXISTS (SELECT 1
|
|
FROM
|
|
Posts
|
|
WHERE
|
|
UpdateAt >= ?
|
|
AND
|
|
ChannelId = ?
|
|
AND
|
|
UserId = ?
|
|
AND
|
|
Type = ?
|
|
LIMIT 1)`
|
|
|
|
var exist bool
|
|
err := s.GetReplica().Get(&exist, query, options.Time, options.ChannelId, userId, model.PostTypeAutoResponder)
|
|
if err != nil {
|
|
return false, errors.Wrapf(err,
|
|
"failed to check if autoresponse posts in channelId=%s for userId=%s since %s", options.ChannelId, userId, model.GetTimeForMillis(options.Time))
|
|
}
|
|
|
|
return exist, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) GetPostsSinceForSync(options model.GetPostsSinceForSyncOptions, cursor model.GetPostsSinceForSyncCursor, limit int) ([]*model.Post, model.GetPostsSinceForSyncCursor, error) {
|
|
query := s.getQueryBuilder().
|
|
Select("*").
|
|
From("Posts").
|
|
OrderBy("Posts.UpdateAt", "Id").
|
|
Limit(uint64(limit))
|
|
|
|
if options.SinceCreateAt {
|
|
query = query.Where(sq.Or{
|
|
sq.Gt{"Posts.CreateAt": cursor.LastPostCreateAt},
|
|
sq.And{
|
|
sq.Eq{"Posts.CreateAt": cursor.LastPostCreateAt},
|
|
sq.Gt{"Posts.Id": cursor.LastPostCreateID},
|
|
},
|
|
})
|
|
} else {
|
|
query = query.Where(sq.Or{sq.Gt{"Posts.UpdateAt": cursor.LastPostUpdateAt},
|
|
sq.And{sq.Eq{"Posts.UpdateAt": cursor.LastPostUpdateAt}, sq.Gt{"Posts.Id": cursor.LastPostUpdateID}}})
|
|
}
|
|
|
|
if options.ChannelId != "" {
|
|
query = query.Where(sq.Eq{"Posts.ChannelId": options.ChannelId})
|
|
}
|
|
|
|
if !options.IncludeDeleted {
|
|
query = query.Where(sq.Eq{"Posts.DeleteAt": 0})
|
|
}
|
|
|
|
if options.ExcludeRemoteId != "" {
|
|
query = query.Where(sq.NotEq{"COALESCE(Posts.RemoteId,'')": options.ExcludeRemoteId})
|
|
}
|
|
|
|
if options.ExcludeChannelMetadataSystemPosts {
|
|
query = query.Where(sq.NotEq{"Posts.Type": []string{
|
|
model.PostTypeHeaderChange,
|
|
model.PostTypeDisplaynameChange,
|
|
model.PostTypePurposeChange,
|
|
}})
|
|
}
|
|
|
|
queryString, args, err := query.ToSql()
|
|
if err != nil {
|
|
return nil, cursor, errors.Wrap(err, "getpostssinceforsync_tosql")
|
|
}
|
|
|
|
posts := []*model.Post{}
|
|
err = s.GetReplica().Select(&posts, queryString, args...)
|
|
if err != nil {
|
|
return nil, cursor, errors.Wrapf(err, "error getting Posts with channelId=%s", options.ChannelId)
|
|
}
|
|
|
|
if len(posts) != 0 {
|
|
if options.SinceCreateAt {
|
|
cursor.LastPostCreateAt = posts[len(posts)-1].CreateAt
|
|
cursor.LastPostCreateID = posts[len(posts)-1].Id
|
|
} else {
|
|
cursor.LastPostUpdateAt = posts[len(posts)-1].UpdateAt
|
|
cursor.LastPostUpdateID = posts[len(posts)-1].Id
|
|
}
|
|
}
|
|
return posts, cursor, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) GetPostsBefore(rctx request.CTX, options model.GetPostsOptions, sanitizeOptions map[string]bool) (*model.PostList, error) {
|
|
return s.getPostsAround(rctx, true, options, sanitizeOptions)
|
|
}
|
|
|
|
func (s *SqlPostStore) GetPostsAfter(rctx request.CTX, options model.GetPostsOptions, sanitizeOptions map[string]bool) (*model.PostList, error) {
|
|
return s.getPostsAround(rctx, false, options, sanitizeOptions)
|
|
}
|
|
|
|
func (s *SqlPostStore) GetPostsByThread(threadId string, since int64) ([]*model.Post, error) {
|
|
query := s.getQueryBuilder().
|
|
Select("*").
|
|
From("Posts").
|
|
Where(sq.Eq{"RootId": threadId}).
|
|
Where(sq.Eq{"DeleteAt": 0}).
|
|
Where(sq.GtOrEq{"CreateAt": since})
|
|
|
|
result := []*model.Post{}
|
|
err := s.GetReplica().SelectBuilder(&result, query)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to fetch thread posts")
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) getPostsAround(rctx request.CTX, before bool, options model.GetPostsOptions, sanitizeOptions map[string]bool) (*model.PostList, error) {
|
|
if options.Page < 0 {
|
|
return nil, store.NewErrInvalidInput("Post", "<options.Page>", options.Page)
|
|
}
|
|
|
|
if options.PerPage < 0 {
|
|
return nil, store.NewErrInvalidInput("Post", "<options.PerPage>", options.PerPage)
|
|
}
|
|
|
|
offset := options.Page * options.PerPage
|
|
posts := []*postWithExtra{}
|
|
parents := []*model.Post{}
|
|
|
|
var direction string
|
|
var sort string
|
|
if before {
|
|
direction = "<"
|
|
sort = "DESC"
|
|
} else {
|
|
direction = ">"
|
|
sort = "ASC"
|
|
}
|
|
columns := []string{"p.*"}
|
|
if options.CollapsedThreads {
|
|
columns = append(columns,
|
|
"COALESCE(Threads.ReplyCount, 0) as ThreadReplyCount",
|
|
"COALESCE(Threads.LastReplyAt, 0) as LastReplyAt",
|
|
"COALESCE(Threads.Participants, '[]') as ThreadParticipants",
|
|
"ThreadMemberships.Following as IsFollowing",
|
|
)
|
|
}
|
|
query := s.getQueryBuilder().Select(columns...)
|
|
replyCountSubQuery := s.getQueryBuilder().Select("COUNT(*)").From("Posts").Where(sq.Expr("Posts.RootId = (CASE WHEN p.RootId = '' THEN p.Id ELSE p.RootId END)"))
|
|
|
|
conditions := sq.And{
|
|
sq.Expr(`CreateAt `+direction+` (SELECT CreateAt FROM Posts WHERE Id = ?)`, options.PostId),
|
|
sq.Eq{"p.ChannelId": options.ChannelId},
|
|
}
|
|
|
|
if !options.IncludeDeleted {
|
|
replyCountSubQuery = replyCountSubQuery.Where(sq.Expr("Posts.DeleteAt = 0"))
|
|
conditions = append(conditions, sq.Eq{"p.DeleteAt": int(0)})
|
|
}
|
|
|
|
if options.CollapsedThreads {
|
|
conditions = append(conditions, sq.Eq{"RootId": ""})
|
|
query = query.LeftJoin("Threads ON Threads.PostId = p.Id").LeftJoin("ThreadMemberships ON ThreadMemberships.PostId = p.Id AND ThreadMemberships.UserId=?", options.UserId)
|
|
} else {
|
|
query = query.Column(sq.Alias(replyCountSubQuery, "ReplyCount"))
|
|
}
|
|
query = query.From("Posts p").
|
|
Where(conditions).
|
|
// Adding ChannelId and DeleteAt order columns
|
|
// to let mysql choose the "idx_posts_channel_id_delete_at_create_at" index always.
|
|
// See MM-24170.
|
|
OrderBy("p.ChannelId", "p.DeleteAt", "p.CreateAt "+sort).
|
|
Limit(uint64(options.PerPage)).
|
|
Offset(uint64(offset))
|
|
|
|
queryString, args, err := query.ToSql()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "post_tosql")
|
|
}
|
|
err = s.GetReplica().Select(&posts, queryString, args...)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "failed to find Posts with channelId=%s", options.ChannelId)
|
|
}
|
|
|
|
if !options.CollapsedThreads && len(posts) > 0 {
|
|
rootIds := []string{}
|
|
for _, post := range posts {
|
|
rootIds = append(rootIds, post.Id)
|
|
if post.RootId != "" {
|
|
rootIds = append(rootIds, post.RootId)
|
|
}
|
|
}
|
|
rootQuery := s.getQueryBuilder().Select("p.*")
|
|
idQuery := sq.Or{
|
|
sq.Eq{"Id": rootIds},
|
|
}
|
|
rootQuery = rootQuery.Column(sq.Alias(replyCountSubQuery, "ReplyCount"))
|
|
if !options.SkipFetchThreads {
|
|
idQuery = append(idQuery, sq.Eq{"RootId": rootIds}) // preserve original behaviour
|
|
}
|
|
|
|
rootQuery = rootQuery.From("Posts p").
|
|
Where(sq.And{
|
|
idQuery,
|
|
sq.Eq{"p.ChannelId": options.ChannelId},
|
|
}).
|
|
OrderBy("CreateAt DESC")
|
|
|
|
if !options.IncludeDeleted {
|
|
rootQuery = rootQuery.Where(sq.Eq{"p.DeleteAt": 0})
|
|
}
|
|
|
|
rootQueryString, rootArgs, nErr := rootQuery.ToSql()
|
|
|
|
if nErr != nil {
|
|
return nil, errors.Wrap(nErr, "post_tosql")
|
|
}
|
|
nErr = s.GetReplica().Select(&parents, rootQueryString, rootArgs...)
|
|
if nErr != nil {
|
|
return nil, errors.Wrapf(nErr, "failed to find Posts with channelId=%s", options.ChannelId)
|
|
}
|
|
}
|
|
|
|
list, err := s.prepareThreadedResponse(rctx, posts, options.CollapsedThreadsExtended, !before, sanitizeOptions)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, p := range parents {
|
|
list.AddPost(p)
|
|
}
|
|
|
|
return list, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) GetPostIdBeforeTime(channelId string, time int64, collapsedThreads bool) (string, error) {
|
|
return s.getPostIdAroundTime(channelId, time, true, collapsedThreads)
|
|
}
|
|
|
|
func (s *SqlPostStore) GetPostIdAfterTime(channelId string, time int64, collapsedThreads bool) (string, error) {
|
|
return s.getPostIdAroundTime(channelId, time, false, collapsedThreads)
|
|
}
|
|
|
|
func (s *SqlPostStore) getPostIdAroundTime(channelId string, time int64, before bool, collapsedThreads bool) (string, error) {
|
|
var direction sq.Sqlizer
|
|
var sort string
|
|
if before {
|
|
direction = sq.Lt{"CreateAt": time}
|
|
sort = "DESC"
|
|
} else {
|
|
direction = sq.Gt{"CreateAt": time}
|
|
sort = "ASC"
|
|
}
|
|
|
|
conditions := sq.And{
|
|
direction,
|
|
sq.Eq{"Posts.ChannelId": channelId},
|
|
sq.Eq{"Posts.DeleteAt": int(0)},
|
|
}
|
|
if collapsedThreads {
|
|
conditions = sq.And{conditions, sq.Eq{"Posts.RootId": ""}}
|
|
}
|
|
query := s.getQueryBuilder().
|
|
Select("Id").
|
|
From("Posts").
|
|
Where(conditions).
|
|
// Adding ChannelId and DeleteAt order columns
|
|
// to let mysql choose the "idx_posts_channel_id_delete_at_create_at" index always.
|
|
// See MM-23369.
|
|
OrderBy("Posts.ChannelId", "Posts.DeleteAt", "Posts.CreateAt "+sort).
|
|
Limit(1)
|
|
|
|
queryString, args, err := query.ToSql()
|
|
if err != nil {
|
|
return "", errors.Wrap(err, "post_tosql")
|
|
}
|
|
|
|
var postId string
|
|
if err := s.GetMaster().Get(&postId, queryString, args...); err != nil {
|
|
if err != sql.ErrNoRows {
|
|
return "", errors.Wrapf(err, "failed to get Post id with channelId=%s", channelId)
|
|
}
|
|
}
|
|
|
|
return postId, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) GetPostAfterTime(channelId string, time int64, collapsedThreads bool) (*model.Post, error) {
|
|
conditions := sq.And{
|
|
sq.Gt{"Posts.CreateAt": time},
|
|
sq.Eq{"Posts.ChannelId": channelId},
|
|
sq.Eq{"Posts.DeleteAt": int(0)},
|
|
}
|
|
if collapsedThreads {
|
|
conditions = sq.And{conditions, sq.Eq{"RootId": ""}}
|
|
}
|
|
query := s.getQueryBuilder().
|
|
Select("*").
|
|
From("Posts").
|
|
Where(conditions).
|
|
// Adding ChannelId and DeleteAt order columns
|
|
// to let mysql choose the "idx_posts_channel_id_delete_at_create_at" index always.
|
|
// See MM-23369.
|
|
OrderBy("Posts.ChannelId", "Posts.DeleteAt", "Posts.CreateAt ASC").
|
|
Limit(1)
|
|
|
|
queryString, args, err := query.ToSql()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "post_tosql")
|
|
}
|
|
|
|
var post model.Post
|
|
if err := s.GetMaster().Get(&post, queryString, args...); err != nil {
|
|
if err != sql.ErrNoRows {
|
|
return nil, errors.Wrapf(err, "failed to get Post with channelId=%s", channelId)
|
|
}
|
|
}
|
|
|
|
return &post, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) getRootPosts(channelId string, offset int, limit int, skipFetchThreads bool, includeDeleted bool) ([]*model.Post, error) {
|
|
posts := []*model.Post{}
|
|
var fetchQuery string
|
|
if skipFetchThreads {
|
|
fetchQuery = "SELECT p.*, (SELECT COUNT(*) FROM Posts WHERE Posts.RootId = (CASE WHEN p.RootId = '' THEN p.Id ELSE p.RootId END)) as ReplyCount FROM Posts p WHERE p.ChannelId = ? ORDER BY p.CreateAt DESC LIMIT ? OFFSET ?"
|
|
if !includeDeleted {
|
|
fetchQuery = "SELECT p.*, (SELECT COUNT(*) FROM Posts WHERE Posts.RootId = (CASE WHEN p.RootId = '' THEN p.Id ELSE p.RootId END) AND Posts.DeleteAt = 0) as ReplyCount FROM Posts p WHERE p.ChannelId = ? AND p.DeleteAt = 0 ORDER BY p.CreateAt DESC LIMIT ? OFFSET ?"
|
|
}
|
|
} else {
|
|
fetchQuery = "SELECT * FROM Posts WHERE Posts.ChannelId = ? ORDER BY Posts.CreateAt DESC LIMIT ? OFFSET ?"
|
|
if !includeDeleted {
|
|
fetchQuery = "SELECT * FROM Posts WHERE Posts.ChannelId = ? AND Posts.DeleteAt = 0 ORDER BY Posts.CreateAt DESC LIMIT ? OFFSET ?"
|
|
}
|
|
}
|
|
|
|
err := s.GetReplica().Select(&posts, fetchQuery, channelId, limit, offset)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to find Posts")
|
|
}
|
|
return posts, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) getParentsPosts(channelId string, offset int, limit int, skipFetchThreads bool, includeDeleted bool) ([]*model.Post, error) {
|
|
if s.DriverName() == model.DatabaseDriverPostgres {
|
|
return s.getParentsPostsPostgreSQL(channelId, offset, limit, skipFetchThreads, includeDeleted)
|
|
}
|
|
|
|
deleteAtCondition := "AND DeleteAt = 0"
|
|
if includeDeleted {
|
|
deleteAtCondition = ""
|
|
}
|
|
|
|
// query parent Ids first
|
|
roots := []string{}
|
|
rootQuery := `
|
|
SELECT DISTINCT
|
|
q.RootId
|
|
FROM
|
|
(SELECT
|
|
Posts.RootId
|
|
FROM
|
|
Posts
|
|
WHERE
|
|
ChannelId = ? ` + deleteAtCondition + `
|
|
ORDER BY CreateAt DESC
|
|
LIMIT ? OFFSET ?) q
|
|
WHERE q.RootId != ''`
|
|
|
|
err := s.GetReplica().Select(&roots, rootQuery, channelId, limit, offset)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to find Posts")
|
|
}
|
|
if len(roots) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
cols := []string{"p.*"}
|
|
var where sq.Sqlizer
|
|
where = sq.Eq{"p.Id": roots}
|
|
if skipFetchThreads {
|
|
col := "(SELECT COUNT(*) FROM Posts WHERE Posts.RootId = (CASE WHEN p.RootId = '' THEN p.Id ELSE p.RootId END)) as ReplyCount"
|
|
if !includeDeleted {
|
|
col = "(SELECT COUNT(*) FROM Posts WHERE Posts.RootId = (CASE WHEN p.RootId = '' THEN p.Id ELSE p.RootId END) AND Posts.DeleteAt = 0) as ReplyCount"
|
|
}
|
|
cols = append(cols, col)
|
|
} else {
|
|
where = sq.Or{
|
|
where,
|
|
sq.Eq{"p.RootId": roots},
|
|
}
|
|
}
|
|
|
|
query := s.getQueryBuilder().
|
|
Select(cols...).
|
|
From("Posts p").
|
|
Where(sq.And{
|
|
where,
|
|
sq.Eq{"p.ChannelId": channelId},
|
|
}).
|
|
OrderBy("p.CreateAt")
|
|
|
|
if !includeDeleted {
|
|
query = query.Where(sq.Eq{"p.DeleteAt": 0})
|
|
}
|
|
|
|
sql, args, err := query.ToSql()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "ParentPosts_Tosql")
|
|
}
|
|
|
|
posts := []*model.Post{}
|
|
err = s.GetReplica().Select(&posts, sql, args...)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to find Posts")
|
|
}
|
|
return posts, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) getParentsPostsPostgreSQL(channelId string, offset int, limit int, skipFetchThreads bool, includeDeleted bool) ([]*model.Post, error) {
|
|
posts := []*model.Post{}
|
|
replyCountQuery := ""
|
|
onStatement := "q1.RootId = q2.Id"
|
|
if skipFetchThreads {
|
|
replyCountQuery = ` ,(SELECT COUNT(*) FROM Posts WHERE Posts.RootId = (CASE WHEN q2.RootId = '' THEN q2.Id ELSE q2.RootId END)) as ReplyCount`
|
|
if !includeDeleted {
|
|
replyCountQuery = ` ,(SELECT COUNT(*) FROM Posts WHERE Posts.RootId = (CASE WHEN q2.RootId = '' THEN q2.Id ELSE q2.RootId END) AND Posts.DeleteAt = 0) as ReplyCount`
|
|
}
|
|
} else {
|
|
onStatement += " OR q1.RootId = q2.RootId"
|
|
}
|
|
|
|
deleteAtQueryCondition := "AND q2.DeleteAt = 0"
|
|
deleteAtSubQueryCondition := "AND Posts.DeleteAt = 0"
|
|
if includeDeleted {
|
|
deleteAtQueryCondition, deleteAtSubQueryCondition = "", ""
|
|
}
|
|
|
|
err := s.GetReplica().Select(&posts,
|
|
`SELECT q2.*`+replyCountQuery+`
|
|
FROM
|
|
Posts q2
|
|
INNER JOIN
|
|
(SELECT DISTINCT
|
|
q3.RootId
|
|
FROM
|
|
(SELECT
|
|
Posts.RootId
|
|
FROM
|
|
Posts
|
|
WHERE
|
|
Posts.ChannelId = ? `+deleteAtSubQueryCondition+`
|
|
ORDER BY Posts.CreateAt DESC
|
|
LIMIT ? OFFSET ?) q3
|
|
WHERE q3.RootId != '') q1
|
|
ON `+onStatement+`
|
|
WHERE
|
|
q2.ChannelId = ? `+deleteAtQueryCondition+`
|
|
ORDER BY q2.CreateAt`, channelId, limit, offset, channelId)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "failed to find Posts with channelId=%s", channelId)
|
|
}
|
|
return posts, nil
|
|
}
|
|
|
|
// GetNthRecentPostTime returns the CreateAt time of the nth most recent post.
|
|
func (s *SqlPostStore) GetNthRecentPostTime(n int64) (int64, error) {
|
|
if n <= 0 {
|
|
return 0, errors.New("n can't be less than 1")
|
|
}
|
|
|
|
builder := s.getQueryBuilder().
|
|
Select("CreateAt").
|
|
From("Posts p").
|
|
// Consider users posts only for cloud limit
|
|
Where(sq.And{
|
|
sq.Eq{"p.Type": ""},
|
|
sq.Expr("p.UserId NOT IN (SELECT UserId FROM Bots)"),
|
|
}).
|
|
OrderBy("p.CreateAt DESC").
|
|
Limit(1).
|
|
Offset(uint64(n - 1))
|
|
|
|
query, queryArgs, err := builder.ToSql()
|
|
if err != nil {
|
|
return 0, errors.Wrap(err, "GetNthRecentPostTime_tosql")
|
|
}
|
|
|
|
var createAt int64
|
|
if err := s.GetMaster().Get(&createAt, query, queryArgs...); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return 0, store.NewErrNotFound("Post", "none")
|
|
}
|
|
|
|
return 0, errors.Wrapf(err, "failed to get the Nth Post=%d", n)
|
|
}
|
|
|
|
return createAt, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) buildCreateDateFilterClause(params *model.SearchParams, builder sq.SelectBuilder) sq.SelectBuilder {
|
|
// handle after: before: on: filters
|
|
if params.OnDate != "" {
|
|
onDateStart, onDateEnd := params.GetOnDateMillis()
|
|
// between `on date` start of day and end of day
|
|
builder = builder.Where("CreateAt BETWEEN ? AND ?", onDateStart, onDateEnd)
|
|
return builder
|
|
}
|
|
|
|
if params.ExcludedDate != "" {
|
|
excludedDateStart, excludedDateEnd := params.GetExcludedDateMillis()
|
|
builder = builder.Where("CreateAt NOT BETWEEN ? AND ?", excludedDateStart, excludedDateEnd)
|
|
}
|
|
|
|
if params.AfterDate != "" {
|
|
afterDate := params.GetAfterDateMillis()
|
|
// greater than `after date`
|
|
builder = builder.Where("CreateAt >= ?", afterDate)
|
|
}
|
|
|
|
if params.BeforeDate != "" {
|
|
beforeDate := params.GetBeforeDateMillis()
|
|
// less than `before date`
|
|
builder = builder.Where("CreateAt <= ?", beforeDate)
|
|
}
|
|
|
|
if params.ExcludedAfterDate != "" {
|
|
afterDate := params.GetExcludedAfterDateMillis()
|
|
builder = builder.Where("CreateAt < ?", afterDate)
|
|
}
|
|
|
|
if params.ExcludedBeforeDate != "" {
|
|
beforeDate := params.GetExcludedBeforeDateMillis()
|
|
builder = builder.Where("CreateAt > ?", beforeDate)
|
|
}
|
|
|
|
return builder
|
|
}
|
|
|
|
func (s *SqlPostStore) buildSearchTeamFilterClause(teamId string, builder sq.SelectBuilder) sq.SelectBuilder {
|
|
if teamId == "" {
|
|
return builder
|
|
}
|
|
|
|
return builder.Where(sq.Or{
|
|
sq.Eq{"TeamId": teamId},
|
|
sq.Eq{"TeamId": ""},
|
|
})
|
|
}
|
|
|
|
func (s *SqlPostStore) buildSearchChannelFilterClause(channels []string, exclusion bool, byName bool, builder sq.SelectBuilder) sq.SelectBuilder {
|
|
if len(channels) == 0 {
|
|
return builder
|
|
}
|
|
|
|
if byName {
|
|
if exclusion {
|
|
return builder.Where(sq.NotEq{"Name": channels})
|
|
}
|
|
return builder.Where(sq.Eq{"Name": channels})
|
|
}
|
|
|
|
if exclusion {
|
|
return builder.Where(sq.NotEq{"Id": channels})
|
|
}
|
|
return builder.Where(sq.Eq{"Id": channels})
|
|
}
|
|
|
|
func (s *SqlPostStore) buildSearchUserFilterClause(users []string, exclusion bool, byUsername bool, builder sq.SelectBuilder) sq.SelectBuilder {
|
|
if len(users) == 0 {
|
|
return builder
|
|
}
|
|
|
|
if byUsername {
|
|
if exclusion {
|
|
return builder.Where(sq.NotEq{"Username": users})
|
|
}
|
|
return builder.Where(sq.Eq{"Username": users})
|
|
}
|
|
|
|
if exclusion {
|
|
return builder.Where(sq.NotEq{"Id": users})
|
|
}
|
|
return builder.Where(sq.Eq{"Id": users})
|
|
}
|
|
|
|
func (s *SqlPostStore) buildSearchPostFilterClause(teamID string, fromUsers []string, excludedUsers []string, userByUsername bool, builder sq.SelectBuilder) (sq.SelectBuilder, error) {
|
|
if len(fromUsers) == 0 && len(excludedUsers) == 0 {
|
|
return builder, nil
|
|
}
|
|
|
|
// Sub-query builder.
|
|
sb := s.getSubQueryBuilder().Select("Id")
|
|
if teamID == "" {
|
|
// Cross-team search: don't filter by team membership
|
|
sb = sb.From("Users")
|
|
} else {
|
|
// Team-scoped search: filter by team membership
|
|
sb = sb.From("Users, TeamMembers").Where(
|
|
sq.And{
|
|
sq.Eq{"TeamMembers.TeamId": teamID},
|
|
sq.Expr("Users.Id = TeamMembers.UserId"),
|
|
})
|
|
}
|
|
sb = s.buildSearchUserFilterClause(fromUsers, false, userByUsername, sb)
|
|
sb = s.buildSearchUserFilterClause(excludedUsers, true, userByUsername, sb)
|
|
subQuery, subQueryArgs, err := sb.ToSql()
|
|
if err != nil {
|
|
return sq.SelectBuilder{}, err
|
|
}
|
|
|
|
/*
|
|
* Squirrel does not support a sub-query in the WHERE condition.
|
|
* https://github.com/Masterminds/squirrel/issues/299
|
|
*/
|
|
return builder.Where("UserId IN ("+subQuery+")", subQueryArgs...), nil
|
|
}
|
|
|
|
func (s *SqlPostStore) Search(teamId string, userId string, params *model.SearchParams) (*model.PostList, error) {
|
|
return s.search(teamId, userId, params, true, true)
|
|
}
|
|
|
|
func (s *SqlPostStore) search(teamId string, userId string, params *model.SearchParams, channelsByName bool, userByUsername bool) (*model.PostList, error) {
|
|
list := model.NewPostList()
|
|
if params.Terms == "" && params.ExcludedTerms == "" &&
|
|
len(params.InChannels) == 0 && len(params.ExcludedChannels) == 0 &&
|
|
len(params.FromUsers) == 0 && len(params.ExcludedUsers) == 0 &&
|
|
params.OnDate == "" && params.AfterDate == "" && params.BeforeDate == "" {
|
|
return list, nil
|
|
}
|
|
|
|
baseQuery := s.getQueryBuilder().Select(
|
|
"*",
|
|
"(SELECT COUNT(*) FROM Posts WHERE Posts.RootId = (CASE WHEN q2.RootId = '' THEN q2.Id ELSE q2.RootId END) AND Posts.DeleteAt = 0) as ReplyCount",
|
|
).From("Posts q2").
|
|
Where("q2.DeleteAt = 0").
|
|
Where(fmt.Sprintf("q2.Type NOT LIKE '%s%%'", model.PostSystemMessagePrefix)).
|
|
OrderByClause("q2.CreateAt DESC").
|
|
Limit(100)
|
|
|
|
var err error
|
|
baseQuery, err = s.buildSearchPostFilterClause(teamId, params.FromUsers, params.ExcludedUsers, userByUsername, baseQuery)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to build search post filter clause")
|
|
}
|
|
baseQuery = s.buildCreateDateFilterClause(params, baseQuery)
|
|
|
|
termMap := map[string]bool{}
|
|
terms := params.Terms
|
|
excludedTerms := params.ExcludedTerms
|
|
|
|
searchType := "Message"
|
|
if params.IsHashtag {
|
|
searchType = "Hashtags"
|
|
for term := range strings.SplitSeq(terms, " ") {
|
|
termMap[strings.ToUpper(term)] = true
|
|
}
|
|
}
|
|
|
|
for _, c := range s.specialSearchChars() {
|
|
if !params.IsHashtag {
|
|
terms = strings.Replace(terms, c, " ", -1)
|
|
}
|
|
excludedTerms = strings.Replace(excludedTerms, c, " ", -1)
|
|
}
|
|
|
|
if terms == "" && excludedTerms == "" {
|
|
// we've already confirmed that we have a channel or user to search for
|
|
} else {
|
|
// Parse text for wildcards
|
|
terms = wildCardRegex.ReplaceAllLiteralString(terms, ":* ")
|
|
excludedTerms = wildCardRegex.ReplaceAllLiteralString(excludedTerms, ":* ")
|
|
|
|
simpleSearch := false
|
|
// Replace spaces with to_tsquery symbols
|
|
replaceSpaces := func(input string, excludedInput bool) string {
|
|
if input == "" {
|
|
return input
|
|
}
|
|
|
|
// Remove extra spaces
|
|
input = strings.Join(strings.Fields(input), " ")
|
|
|
|
// Replace spaces within quoted strings with '<->'
|
|
input = quotedStringsRegex.ReplaceAllStringFunc(input, func(match string) string {
|
|
// If the whole search term is a quoted string,
|
|
// we don't want to do stemming.
|
|
if input == match {
|
|
simpleSearch = true
|
|
}
|
|
return strings.Replace(match, " ", "<->", -1)
|
|
})
|
|
|
|
// Replace spaces outside of quoted substrings with '&' or '|'
|
|
replacer := "&"
|
|
if excludedInput || params.OrTerms {
|
|
replacer = "|"
|
|
}
|
|
input = strings.Replace(input, " ", replacer, -1)
|
|
|
|
return input
|
|
}
|
|
|
|
tsQueryClause := replaceSpaces(terms, false)
|
|
excludedClause := replaceSpaces(excludedTerms, true)
|
|
if excludedClause != "" {
|
|
tsQueryClause += " &!(" + excludedClause + ")"
|
|
}
|
|
|
|
textSearchCfg := s.pgDefaultTextSearchConfig
|
|
if simpleSearch {
|
|
textSearchCfg = "simple"
|
|
}
|
|
|
|
searchClause := fmt.Sprintf("to_tsvector('%[1]s', %[2]s) @@ to_tsquery('%[1]s', ?)", textSearchCfg, searchType)
|
|
baseQuery = baseQuery.Where(searchClause, tsQueryClause)
|
|
}
|
|
|
|
inQuery := s.getSubQueryBuilder().Select("Id").
|
|
From("Channels, ChannelMembers").
|
|
Where("Id = ChannelId")
|
|
|
|
if !params.IncludeDeletedChannels {
|
|
inQuery = inQuery.Where("Channels.DeleteAt = 0")
|
|
}
|
|
|
|
if !params.SearchWithoutUserId {
|
|
inQuery = inQuery.Where("ChannelMembers.UserId = ?", userId)
|
|
}
|
|
|
|
inQuery = s.buildSearchTeamFilterClause(teamId, inQuery)
|
|
inQuery = s.buildSearchChannelFilterClause(params.InChannels, false, channelsByName, inQuery)
|
|
inQuery = s.buildSearchChannelFilterClause(params.ExcludedChannels, true, channelsByName, inQuery)
|
|
|
|
inQueryClause, inQueryClauseArgs, err := inQuery.ToSql()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
baseQuery = baseQuery.Where(fmt.Sprintf("ChannelId IN (%s)", inQueryClause), inQueryClauseArgs...)
|
|
|
|
searchQuery, searchQueryArgs, err := baseQuery.ToSql()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var posts []*model.Post
|
|
|
|
if err := s.GetSearchReplicaX().Select(&posts, searchQuery, searchQueryArgs...); err != nil {
|
|
mlog.Warn("Query error searching posts.", mlog.String("error", trimInput(err.Error())))
|
|
// Don't return the error to the caller as it is of no use to the user. Instead return an empty set of search results.
|
|
} else {
|
|
for _, p := range posts {
|
|
if searchType == "Hashtags" {
|
|
exactMatch := false
|
|
for tag := range strings.SplitSeq(p.Hashtags, " ") {
|
|
if termMap[strings.ToUpper(tag)] {
|
|
exactMatch = true
|
|
break
|
|
}
|
|
}
|
|
if !exactMatch {
|
|
continue
|
|
}
|
|
}
|
|
list.AddPost(p)
|
|
list.AddOrder(p.Id)
|
|
}
|
|
}
|
|
list.MakeNonNil()
|
|
return list, nil
|
|
}
|
|
|
|
// TODO: convert to squirrel HW
|
|
func (s *SqlPostStore) AnalyticsUserCountsWithPostsByDay(teamId string) (model.AnalyticsRows, error) {
|
|
var args []any
|
|
query :=
|
|
`SELECT DISTINCT
|
|
DATE(FROM_UNIXTIME(Posts.CreateAt / 1000)) AS Name,
|
|
COUNT(DISTINCT Posts.UserId) AS Value
|
|
FROM Posts`
|
|
|
|
if teamId != "" {
|
|
query += " INNER JOIN Channels ON Posts.ChannelId = Channels.Id AND Channels.TeamId = ? AND"
|
|
args = []any{teamId}
|
|
} else {
|
|
query += " WHERE"
|
|
}
|
|
|
|
query += ` Posts.CreateAt >= ? AND Posts.CreateAt <= ?
|
|
GROUP BY DATE(FROM_UNIXTIME(Posts.CreateAt / 1000))
|
|
ORDER BY Name DESC
|
|
LIMIT 30`
|
|
|
|
if s.DriverName() == model.DatabaseDriverPostgres {
|
|
query =
|
|
`SELECT
|
|
TO_CHAR(DATE(TO_TIMESTAMP(Posts.CreateAt / 1000)), 'YYYY-MM-DD') AS Name, COUNT(DISTINCT Posts.UserId) AS Value
|
|
FROM Posts`
|
|
|
|
if teamId != "" {
|
|
query += " INNER JOIN Channels ON Posts.ChannelId = Channels.Id AND Channels.TeamId = ? AND"
|
|
args = []any{teamId}
|
|
} else {
|
|
query += " WHERE"
|
|
}
|
|
|
|
query += ` Posts.CreateAt >= ? AND Posts.CreateAt <= ?
|
|
GROUP BY DATE(TO_TIMESTAMP(Posts.CreateAt / 1000))
|
|
ORDER BY Name DESC
|
|
LIMIT 30`
|
|
}
|
|
|
|
end := utils.MillisFromTime(utils.EndOfDay(utils.Yesterday()))
|
|
start := utils.MillisFromTime(utils.StartOfDay(utils.Yesterday().AddDate(0, 0, -31)))
|
|
args = append(args, start, end)
|
|
|
|
rows := model.AnalyticsRows{}
|
|
err := s.GetReplica().Select(
|
|
&rows,
|
|
query,
|
|
args...)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "failed to find Posts with teamId=%s", teamId)
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) countBotPostsByDay(teamID, startDay, endDay string) (model.AnalyticsRows, error) {
|
|
var query sq.SelectBuilder
|
|
if teamID != "" {
|
|
query = s.getQueryBuilder().
|
|
Select("TO_CHAR(day, 'YYYY-MM-DD') as Name, num as Value").
|
|
From("bot_posts_by_team_day").
|
|
Where(sq.Eq{"teamid": teamID})
|
|
} else {
|
|
query = s.getQueryBuilder().
|
|
Select("TO_CHAR(day, 'YYYY-MM-DD') as Name, COALESCE(SUM(num), 0) as Value").
|
|
From("bot_posts_by_team_day").
|
|
GroupBy("Name")
|
|
}
|
|
|
|
query = query.
|
|
Where(sq.GtOrEq{"day": startDay}).
|
|
Where(sq.LtOrEq{"day": endDay}).
|
|
OrderBy("Name DESC").
|
|
Limit(30)
|
|
|
|
rows := model.AnalyticsRows{}
|
|
err := s.GetReplica().SelectBuilder(&rows, query)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "failed to find bot posts with teamId=%s", teamID)
|
|
}
|
|
|
|
return rows, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) countPostsByDay(teamID, startDay, endDay string) (model.AnalyticsRows, error) {
|
|
var query sq.SelectBuilder
|
|
if teamID != "" {
|
|
query = s.getQueryBuilder().
|
|
Select("TO_CHAR(day, 'YYYY-MM-DD') as Name, num as Value").
|
|
From("posts_by_team_day").
|
|
Where(sq.Eq{"teamid": teamID})
|
|
} else {
|
|
query = s.getQueryBuilder().
|
|
Select("TO_CHAR(day, 'YYYY-MM-DD') as Name, COALESCE(SUM(num), 0) as Value").
|
|
From("posts_by_team_day").
|
|
GroupBy("Name")
|
|
}
|
|
|
|
query = query.
|
|
Where(sq.GtOrEq{"day": startDay}).
|
|
Where(sq.LtOrEq{"day": endDay}).
|
|
OrderBy("Name DESC").
|
|
Limit(30)
|
|
|
|
rows := model.AnalyticsRows{}
|
|
err := s.GetReplica().SelectBuilder(&rows, query)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "failed to find posts with teamId=%s", teamID)
|
|
}
|
|
|
|
return rows, nil
|
|
}
|
|
|
|
// TODO: convert to squirrel HW
|
|
func (s *SqlPostStore) AnalyticsPostCountsByDay(options *model.AnalyticsPostCountsOptions) (model.AnalyticsRows, error) {
|
|
if s.DriverName() == model.DatabaseDriverPostgres {
|
|
endDay := utils.Yesterday().Format("2006-01-02")
|
|
startDay := utils.Yesterday().AddDate(0, 0, -31).Format("2006-01-02")
|
|
if options.YesterdayOnly {
|
|
startDay = utils.Yesterday().AddDate(0, 0, -1).Format("2006-01-02")
|
|
}
|
|
// Use materialized views
|
|
if options.BotsOnly {
|
|
return s.countBotPostsByDay(options.TeamId, startDay, endDay)
|
|
}
|
|
return s.countPostsByDay(options.TeamId, startDay, endDay)
|
|
}
|
|
|
|
var args []any
|
|
query :=
|
|
`SELECT
|
|
DATE(FROM_UNIXTIME(Posts.CreateAt / 1000)) AS Name,
|
|
COUNT(Posts.Id) AS Value
|
|
FROM Posts`
|
|
|
|
if options.BotsOnly {
|
|
query += " INNER JOIN Bots ON Posts.UserId = Bots.Userid"
|
|
}
|
|
|
|
if options.TeamId != "" {
|
|
query += " INNER JOIN Channels ON Posts.ChannelId = Channels.Id AND Channels.TeamId = ? AND"
|
|
args = []any{options.TeamId}
|
|
} else {
|
|
query += " WHERE"
|
|
}
|
|
|
|
query += ` Posts.CreateAt <= ?
|
|
AND Posts.CreateAt >= ?
|
|
GROUP BY DATE(FROM_UNIXTIME(Posts.CreateAt / 1000))
|
|
ORDER BY Name DESC
|
|
LIMIT 30`
|
|
|
|
end := utils.MillisFromTime(utils.EndOfDay(utils.Yesterday()))
|
|
start := utils.MillisFromTime(utils.StartOfDay(utils.Yesterday().AddDate(0, 0, -31)))
|
|
if options.YesterdayOnly {
|
|
start = utils.MillisFromTime(utils.StartOfDay(utils.Yesterday().AddDate(0, 0, -1)))
|
|
}
|
|
args = append(args, end, start)
|
|
|
|
rows := model.AnalyticsRows{}
|
|
err := s.GetReplica().Select(&rows, query, args...)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "failed to find Posts with teamId=%s", options.TeamId)
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) countByTeam(teamID string) (int64, error) {
|
|
query := s.getQueryBuilder().
|
|
Select("COALESCE(SUM(num), 0) AS total").
|
|
From("posts_by_team_day")
|
|
|
|
if teamID != "" {
|
|
query = query.Where(sq.Eq{"teamid": teamID})
|
|
}
|
|
|
|
var v int64
|
|
err := s.GetReplica().GetBuilder(&v, query)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to count Posts by team: %w, teamID: %s", err, teamID)
|
|
}
|
|
|
|
return v, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) AnalyticsPostCountByTeam(teamID string) (int64, error) {
|
|
if s.DriverName() == model.DatabaseDriverPostgres {
|
|
return s.countByTeam(teamID)
|
|
}
|
|
|
|
return s.AnalyticsPostCount(&model.PostCountOptions{TeamId: teamID})
|
|
}
|
|
|
|
func (s *SqlPostStore) AnalyticsPostCount(options *model.PostCountOptions) (int64, error) {
|
|
query := s.getQueryBuilder().
|
|
Select("COUNT(*) AS Value").
|
|
From("Posts p")
|
|
|
|
if options.TeamId != "" {
|
|
query = query.
|
|
Join("Channels c ON (c.Id = p.ChannelId)").
|
|
Where(sq.Eq{"c.TeamId": options.TeamId})
|
|
}
|
|
|
|
if options.UsersPostsOnly {
|
|
query = query.Where(sq.And{
|
|
sq.Eq{"p.Type": ""},
|
|
sq.Expr("p.UserId NOT IN (SELECT UserId FROM Bots)"),
|
|
})
|
|
}
|
|
|
|
if options.MustHaveFile {
|
|
query = query.Where(sq.Or{sq.NotEq{"p.FileIds": "[]"}, sq.NotEq{"p.Filenames": "[]"}})
|
|
}
|
|
|
|
if options.MustHaveHashtag {
|
|
query = query.Where(sq.NotEq{"p.Hashtags": ""})
|
|
}
|
|
|
|
if options.ExcludeDeleted {
|
|
query = query.Where(sq.Eq{"p.DeleteAt": 0})
|
|
}
|
|
|
|
if options.ExcludeSystemPosts {
|
|
query = query.Where("p.Type NOT LIKE 'system_%'")
|
|
}
|
|
|
|
if options.SinceUpdateAt > 0 {
|
|
query = query.Where(sq.Or{
|
|
sq.Gt{"p.UpdateAt": options.SinceUpdateAt},
|
|
sq.And{
|
|
sq.Eq{"p.UpdateAt": options.SinceUpdateAt},
|
|
sq.Gt{"p.Id": options.SincePostID},
|
|
},
|
|
})
|
|
}
|
|
|
|
if options.UntilUpdateAt > 0 {
|
|
query = query.Where(sq.LtOrEq{"p.UpdateAt": options.UntilUpdateAt})
|
|
}
|
|
|
|
var v int64
|
|
err := s.GetReplica().GetBuilder(&v, query)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("post_tosql failed or failed to count Posts: %w", err)
|
|
}
|
|
|
|
return v, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) GetPostsCreatedAt(channelId string, time int64) ([]*model.Post, error) {
|
|
query := `SELECT * FROM Posts WHERE CreateAt = ? AND ChannelId = ?`
|
|
|
|
posts := []*model.Post{}
|
|
err := s.GetReplica().Select(&posts, query, time, channelId)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "failed to find Posts with channelId=%s", channelId)
|
|
}
|
|
return posts, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) GetPostsByIds(postIds []string) ([]*model.Post, error) {
|
|
baseQuery := s.getQueryBuilder().Select("p.*, (SELECT count(*) FROM Posts WHERE Posts.RootId = (CASE WHEN p.RootId = '' THEN p.Id ELSE p.RootId END) AND Posts.DeleteAt = 0) as ReplyCount").
|
|
From("Posts p").
|
|
Where(sq.Eq{"p.Id": postIds}).
|
|
OrderBy("CreateAt DESC")
|
|
|
|
query, args, err := baseQuery.ToSql()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "getPostsByIds_tosql")
|
|
}
|
|
posts := []*model.Post{}
|
|
|
|
err = s.GetReplica().Select(&posts, query, args...)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to find Posts")
|
|
}
|
|
if len(posts) == 0 {
|
|
return nil, store.NewErrNotFound("Post", fmt.Sprintf("postIds=%v", postIds))
|
|
}
|
|
return posts, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) GetEditHistoryForPost(postId string) ([]*model.Post, error) {
|
|
builder := s.getQueryBuilder().
|
|
Select("*").
|
|
From("Posts").
|
|
Where(sq.Eq{"Posts.OriginalId": postId}).
|
|
OrderBy("Posts.EditAt DESC")
|
|
|
|
queryString, args, err := builder.ToSql()
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, store.NewErrNotFound("Post", postId)
|
|
}
|
|
return nil, errors.Wrap(err, "failed to find post history")
|
|
}
|
|
|
|
posts := []*model.Post{}
|
|
err = s.GetReplica().Select(&posts, queryString, args...)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "error getting posts edit history with postId=%s", postId)
|
|
}
|
|
|
|
if len(posts) == 0 {
|
|
return nil, store.NewErrNotFound("failed to find post history", postId)
|
|
}
|
|
|
|
return posts, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) GetPostsBatchForIndexing(startTime int64, startPostID string, limit int) ([]*model.PostForIndexing, error) {
|
|
posts := []*model.PostForIndexing{}
|
|
|
|
var err error
|
|
// In order to use an index scan, we need to do
|
|
// (CreateAt, Id) > (?, ?)
|
|
// The wrong choice for any of the two databases makes the query go from
|
|
// milliseconds to dozens of seconds.
|
|
// More information in: https://github.com/mattermost/mattermost/pull/26517
|
|
// and https://community.mattermost.com/core/pl/ui5dz96shinetb8nq83myggbma
|
|
|
|
query := `SELECT
|
|
Posts.*, Channels.TeamId
|
|
FROM Posts
|
|
LEFT JOIN
|
|
Channels
|
|
ON
|
|
Posts.ChannelId = Channels.Id
|
|
WHERE
|
|
(Posts.CreateAt, Posts.Id) > (?, ?)
|
|
ORDER BY
|
|
Posts.CreateAt ASC, Posts.Id ASC
|
|
LIMIT
|
|
?`
|
|
err = s.GetSearchReplicaX().Select(&posts, query, startTime, startPostID, limit)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to find Posts")
|
|
}
|
|
return posts, nil
|
|
}
|
|
|
|
// PermanentDeleteBatchForRetentionPolicies deletes a batch of records which are affected by
|
|
// the global or a granular retention policy.
|
|
// See `genericPermanentDeleteBatchForRetentionPolicies` for details.
|
|
func (s *SqlPostStore) PermanentDeleteBatchForRetentionPolicies(retentionPolicyBatchConfigs model.RetentionPolicyBatchConfigs, cursor model.RetentionPolicyCursor) (int64, model.RetentionPolicyCursor, error) {
|
|
builder := s.getQueryBuilder().
|
|
Select("Posts.Id").
|
|
From("Posts")
|
|
|
|
if retentionPolicyBatchConfigs.PreservePinnedPosts {
|
|
builder = builder.Where(sq.Or{
|
|
sq.Eq{"Posts.IsPinned": false},
|
|
sq.And{
|
|
sq.Eq{"Posts.IsPinned": true},
|
|
sq.Gt{"Posts.DeleteAt": 0},
|
|
},
|
|
})
|
|
}
|
|
|
|
return genericPermanentDeleteBatchForRetentionPolicies(RetentionPolicyBatchDeletionInfo{
|
|
BaseBuilder: builder,
|
|
Table: "Posts",
|
|
TimeColumn: "CreateAt",
|
|
PrimaryKeys: []string{"Id"},
|
|
ChannelIDTable: "Posts",
|
|
NowMillis: retentionPolicyBatchConfigs.Now,
|
|
GlobalPolicyEndTime: retentionPolicyBatchConfigs.GlobalPolicyEndTime,
|
|
Limit: retentionPolicyBatchConfigs.Limit,
|
|
StoreDeletedIds: true,
|
|
}, s.SqlStore, cursor)
|
|
}
|
|
|
|
func (s *SqlPostStore) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) {
|
|
var query string
|
|
if s.DriverName() == model.DatabaseDriverPostgres {
|
|
query = "DELETE from Posts WHERE Id = any (array (SELECT Id FROM Posts WHERE CreateAt < ? LIMIT ?))"
|
|
} else {
|
|
query = "DELETE from Posts WHERE CreateAt < ? LIMIT ?"
|
|
}
|
|
|
|
sqlResult, err := s.GetMaster().Exec(query, endTime, limit)
|
|
if err != nil {
|
|
return 0, errors.Wrap(err, "failed to delete Posts")
|
|
}
|
|
|
|
rowsAffected, err := sqlResult.RowsAffected()
|
|
if err != nil {
|
|
return 0, errors.Wrap(err, "failed to delete Posts")
|
|
}
|
|
return rowsAffected, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) GetOldest() (*model.Post, error) {
|
|
var post model.Post
|
|
err := s.GetReplica().Get(&post, "SELECT * FROM Posts ORDER BY CreateAt LIMIT 1")
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, store.NewErrNotFound("Post", "none")
|
|
}
|
|
|
|
return nil, errors.Wrap(err, "failed to get oldest Post")
|
|
}
|
|
|
|
return &post, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) determineMaxPostSize() int {
|
|
var maxPostSizeBytes int32
|
|
|
|
if s.DriverName() == model.DatabaseDriverPostgres {
|
|
// The Post.Message column in Postgres has historically been VARCHAR(4000), but
|
|
// may be manually enlarged to support longer posts.
|
|
if err := s.GetReplica().Get(&maxPostSizeBytes, `
|
|
SELECT
|
|
COALESCE(character_maximum_length, 0)
|
|
FROM
|
|
information_schema.columns
|
|
WHERE
|
|
table_name = 'posts'
|
|
AND column_name = 'message'
|
|
`); err != nil {
|
|
mlog.Warn("Unable to determine the maximum supported post size", mlog.Err(err))
|
|
}
|
|
} else {
|
|
mlog.Error("No implementation found to determine the maximum supported post size")
|
|
}
|
|
|
|
// Assume a worst-case representation of four bytes per rune.
|
|
maxPostSize := max(int(maxPostSizeBytes)/4, model.PostMessageMaxRunesV2)
|
|
|
|
mlog.Info("Post.Message has size restrictions", mlog.Int("max_characters", maxPostSize), mlog.Int("max_bytes", maxPostSizeBytes))
|
|
|
|
return maxPostSize
|
|
}
|
|
|
|
// GetMaxPostSize returns the maximum number of runes that may be stored in a post.
|
|
// For any changes, accordingly update the markdown maxLen here - markdown/inspect.go.
|
|
func (s *SqlPostStore) GetMaxPostSize() int {
|
|
s.maxPostSizeOnce.Do(func() {
|
|
s.maxPostSizeCached = s.determineMaxPostSize()
|
|
})
|
|
return s.maxPostSizeCached
|
|
}
|
|
|
|
func (s *SqlPostStore) GetParentsForExportAfter(limit int, afterId string, includeArchivedChannel bool) ([]*model.PostForExport, error) {
|
|
for {
|
|
rootIds := []string{}
|
|
err := s.GetReplica().Select(&rootIds,
|
|
`SELECT
|
|
Id
|
|
FROM
|
|
Posts
|
|
WHERE
|
|
Posts.Id > ?
|
|
AND Posts.RootId = ''
|
|
AND Posts.DeleteAt = 0
|
|
ORDER BY Posts.Id
|
|
LIMIT ?`,
|
|
afterId, limit)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to find Posts")
|
|
}
|
|
|
|
postsForExport := []*model.PostForExport{}
|
|
if len(rootIds) == 0 {
|
|
return postsForExport, nil
|
|
}
|
|
|
|
excludeDeletedCond := sq.And{
|
|
sq.Eq{"Teams.DeleteAt": 0},
|
|
}
|
|
if !includeArchivedChannel {
|
|
excludeDeletedCond = append(excludeDeletedCond, sq.Eq{"Channels.DeleteAt": 0})
|
|
}
|
|
|
|
aggFn := "COALESCE(json_agg(u1.username) FILTER (WHERE u1.username IS NOT NULL), '[]')"
|
|
result := []*model.PostForExport{}
|
|
|
|
builder := s.getQueryBuilder().
|
|
Select(fmt.Sprintf("%s, Users.Username as Username, Teams.Name as TeamName, Channels.Name as ChannelName, %s as FlaggedBy", strings.Join(postSliceColumnsWithName("p1"), ", "), aggFn)).
|
|
FromSelect(sq.Select("*").From("Posts").Where(sq.Eq{"Posts.Id": rootIds}), "p1").
|
|
LeftJoin("Preferences ON p1.Id = Preferences.Name").
|
|
LeftJoin("Users u1 ON Preferences.UserId = u1.Id").
|
|
InnerJoin("Channels ON p1.ChannelId = Channels.Id").
|
|
InnerJoin("Teams ON Channels.TeamId = Teams.Id").
|
|
InnerJoin("Users ON p1.UserId = Users.Id").
|
|
Where(excludeDeletedCond).
|
|
GroupBy(fmt.Sprintf("%s, Users.Username, Teams.Name, Channels.Name", strings.Join(postSliceColumnsWithName("p1"), ", "))).
|
|
OrderBy("p1.Id")
|
|
|
|
query, args, err := builder.ToSql()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "postsForExport_toSql")
|
|
}
|
|
|
|
err = s.GetSearchReplicaX().Select(&result, query, args...)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to find Posts")
|
|
}
|
|
|
|
if len(result) == 0 {
|
|
// All of the posts were in channels or teams that were deleted.
|
|
// Update the afterId and try again.
|
|
afterId = rootIds[len(rootIds)-1]
|
|
continue
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
}
|
|
|
|
func (s *SqlPostStore) GetRepliesForExport(rootId string) ([]*model.ReplyForExport, error) {
|
|
aggFn := "COALESCE(json_agg(u1.username) FILTER (WHERE u1.username IS NOT NULL), '[]')"
|
|
result := []*model.ReplyForExport{}
|
|
|
|
qb := s.getQueryBuilder().Select(fmt.Sprintf("Posts.*, u2.Username as Username, %s as FlaggedBy", aggFn)).
|
|
From("Posts").
|
|
LeftJoin("Preferences ON Posts.Id = Preferences.Name").
|
|
LeftJoin("Users u1 ON Preferences.UserId = u1.Id").
|
|
InnerJoin("Users u2 ON Posts.UserId = u2.Id").
|
|
Where(sq.And{sq.Eq{"Posts.RootId": rootId}, sq.Eq{"Posts.DeleteAt": 0}}).
|
|
GroupBy("Posts.Id, u2.Username").
|
|
OrderBy("Posts.Id")
|
|
|
|
query, args, err := qb.ToSql()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "postsForExport_toSql")
|
|
}
|
|
|
|
err = s.GetSearchReplicaX().Select(&result, query, args...)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to find Posts")
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) GetDirectPostParentsForExportAfter(limit int, afterId string, includeArchivedChannels bool) ([]*model.DirectPostForExport, error) {
|
|
aggFn := "COALESCE(json_agg(u1.username) FILTER (WHERE u1.username IS NOT NULL), '[]')"
|
|
result := []*model.DirectPostForExport{}
|
|
|
|
query := s.getQueryBuilder().
|
|
Select(fmt.Sprintf("p.*, u2.Username as User, %s as FlaggedBy", aggFn)).
|
|
From("Posts p").
|
|
LeftJoin("Preferences ON p.Id = Preferences.Name").
|
|
LeftJoin("Users u1 ON Preferences.UserId = u1.Id").
|
|
Join("Channels ON p.ChannelId = Channels.Id").
|
|
Join("Users u2 ON p.UserId = u2.Id").
|
|
Where(sq.And{
|
|
sq.Gt{"p.Id": afterId},
|
|
sq.Eq{"p.RootId": ""},
|
|
sq.Eq{"p.DeleteAt": 0},
|
|
sq.Eq{"Channels.Type": []model.ChannelType{model.ChannelTypeDirect, model.ChannelTypeGroup}},
|
|
}).
|
|
GroupBy("p.Id, u2.Username").
|
|
OrderBy("p.Id").
|
|
Limit(uint64(limit))
|
|
|
|
if !includeArchivedChannels {
|
|
query = query.Where(
|
|
sq.Eq{"Channels.DeleteAt": 0},
|
|
)
|
|
}
|
|
|
|
queryString, args, err := query.ToSql()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "post_tosql")
|
|
}
|
|
|
|
if err2 := s.GetReplica().Select(&result, queryString, args...); err2 != nil {
|
|
return nil, errors.Wrap(err2, "failed to find Posts")
|
|
}
|
|
var channelIds []string
|
|
for _, p := range result {
|
|
channelIds = append(channelIds, p.ChannelId)
|
|
}
|
|
query = s.getQueryBuilder().
|
|
Select("u.Username as Username, ChannelId, UserId, cm.Roles as Roles, LastViewedAt, MsgCount, MentionCount, MentionCountRoot, cm.NotifyProps as NotifyProps, LastUpdateAt, SchemeUser, SchemeAdmin, (SchemeGuest IS NOT NULL AND SchemeGuest) as SchemeGuest").
|
|
From("ChannelMembers cm").
|
|
Join("Users u ON ( u.Id = cm.UserId )").
|
|
Where(sq.Eq{
|
|
"cm.ChannelId": channelIds,
|
|
})
|
|
|
|
queryString, args, err = query.ToSql()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "post_tosql")
|
|
}
|
|
|
|
channelMembers := []*model.ChannelMemberForExport{}
|
|
if err = s.GetReplica().Select(&channelMembers, queryString, args...); err != nil {
|
|
return nil, errors.Wrap(err, "failed to find ChannelMembers")
|
|
}
|
|
|
|
// Build a map of channels and their posts
|
|
postsChannelMap := make(map[string][]*model.DirectPostForExport)
|
|
for _, post := range result {
|
|
post.ChannelMembers = &[]string{}
|
|
postsChannelMap[post.ChannelId] = append(postsChannelMap[post.ChannelId], post)
|
|
}
|
|
|
|
// Build a map of channels and their members
|
|
channelMembersMap := make(map[string][]string)
|
|
for _, member := range channelMembers {
|
|
channelMembersMap[member.ChannelId] = append(channelMembersMap[member.ChannelId], member.Username)
|
|
}
|
|
|
|
// Populate each post ChannelMembers extracting it from the channelMembersMap
|
|
for channelId := range channelMembersMap {
|
|
for _, post := range postsChannelMap[channelId] {
|
|
*post.ChannelMembers = channelMembersMap[channelId]
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
//nolint:unparam
|
|
func (s *SqlPostStore) SearchPostsForUser(rctx request.CTX, paramsList []*model.SearchParams, userId, teamId string, page, perPage int) (*model.PostSearchResults, error) {
|
|
// Since we don't support paging for DB search, we just return nothing for later pages
|
|
if page > 0 {
|
|
return model.MakePostSearchResults(model.NewPostList(), nil), nil
|
|
}
|
|
|
|
if err := model.IsSearchParamsListValid(paramsList); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
pchan := make(chan store.StoreResult[*model.PostList], len(paramsList))
|
|
|
|
for _, params := range paramsList {
|
|
// remove any unquoted term that contains only non-alphanumeric chars
|
|
// ex: abcd "**" && abc >> abcd "**" abc
|
|
params.Terms = removeNonAlphaNumericUnquotedTerms(params.Terms, " ")
|
|
|
|
wg.Add(1)
|
|
|
|
go func(params *model.SearchParams) {
|
|
defer wg.Done()
|
|
postList, err := s.search(teamId, userId, params, false, false)
|
|
pchan <- store.StoreResult[*model.PostList]{Data: postList, NErr: err}
|
|
}(params)
|
|
}
|
|
|
|
wg.Wait()
|
|
close(pchan)
|
|
|
|
posts := model.NewPostList()
|
|
|
|
for result := range pchan {
|
|
if result.NErr != nil {
|
|
return nil, result.NErr
|
|
}
|
|
posts.Extend(result.Data)
|
|
}
|
|
|
|
posts.SortByCreateAt()
|
|
|
|
return model.MakePostSearchResults(posts, nil), nil
|
|
}
|
|
|
|
func (s *SqlPostStore) GetOldestEntityCreationTime() (int64, error) {
|
|
query := s.getQueryBuilder().Select("MIN(min_createat) min_createat").
|
|
Suffix(`FROM (
|
|
(SELECT MIN(createat) min_createat FROM Posts)
|
|
UNION
|
|
(SELECT MIN(createat) min_createat FROM Users)
|
|
UNION
|
|
(SELECT MIN(createat) min_createat FROM Channels)
|
|
) entities`)
|
|
queryString, args, err := query.ToSql()
|
|
if err != nil {
|
|
return -1, errors.Wrap(err, "post_tosql")
|
|
}
|
|
|
|
var oldest int64
|
|
err = s.GetReplica().Get(&oldest, queryString, args...)
|
|
if err != nil {
|
|
return -1, errors.Wrap(err, "unable to scan oldest entity creation time")
|
|
}
|
|
return oldest, nil
|
|
}
|
|
|
|
// Deletes a thread and a thread membership if the postId is a root post
|
|
func (s *SqlPostStore) permanentDeleteThreads(transaction *sqlxTxWrapper, postIds []string) error {
|
|
query := s.getQueryBuilder().
|
|
Delete("Threads").
|
|
Where(
|
|
sq.Eq{"PostId": postIds},
|
|
)
|
|
if _, err := transaction.ExecBuilder(query); err != nil {
|
|
return errors.Wrap(err, "failed to delete Threads")
|
|
}
|
|
|
|
query = s.getQueryBuilder().
|
|
Delete("ThreadMemberships").
|
|
Where(
|
|
sq.Eq{"PostId": postIds},
|
|
)
|
|
if _, err := transaction.ExecBuilder(query); err != nil {
|
|
return errors.Wrap(err, "failed to delete ThreadMemberships")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SqlPostStore) permanentDeleteReactions(transaction *sqlxTxWrapper, postIds []string) error {
|
|
query := s.getQueryBuilder().
|
|
Delete("Reactions").
|
|
Where(
|
|
sq.Eq{"PostId": postIds},
|
|
)
|
|
if _, err := transaction.ExecBuilder(query); err != nil {
|
|
return errors.Wrap(err, "failed to delete Reactions")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// deleteThread marks a thread as deleted at the given time.
|
|
func (s *SqlPostStore) deleteThread(transaction *sqlxTxWrapper, postId string, deleteAtTime int64) error {
|
|
queryString, args, err := s.getQueryBuilder().
|
|
Update("Threads").
|
|
Set("ThreadDeleteAt", deleteAtTime).
|
|
Where(sq.Eq{"PostId": postId}).
|
|
ToSql()
|
|
if err != nil {
|
|
return errors.Wrapf(err, "failed to create SQL query to mark thread for root post %s as deleted", postId)
|
|
}
|
|
|
|
_, err = transaction.Exec(queryString, args...)
|
|
if err != nil {
|
|
return errors.Wrapf(err, "failed to mark thread for root post %s as deleted", postId)
|
|
}
|
|
|
|
return s.deleteThreadFiles(transaction, postId, deleteAtTime)
|
|
}
|
|
|
|
func (s *SqlPostStore) deleteThreadFiles(transaction *sqlxTxWrapper, postID string, deleteAtTime int64) error {
|
|
var query sq.UpdateBuilder
|
|
if s.DriverName() == model.DatabaseDriverPostgres {
|
|
query = s.getQueryBuilder().Update("FileInfo").
|
|
Set("DeleteAt", deleteAtTime).
|
|
From("Posts")
|
|
} else {
|
|
query = s.getQueryBuilder().Update("FileInfo", "Posts").
|
|
Set("FileInfo.DeleteAt", deleteAtTime)
|
|
}
|
|
|
|
query = query.Where(sq.And{
|
|
sq.Expr("FileInfo.PostId = Posts.Id"),
|
|
sq.Eq{"Posts.RootId": postID},
|
|
})
|
|
|
|
_, err := transaction.ExecBuilder(query)
|
|
if err != nil {
|
|
return errors.Wrapf(err, "failed to mark files of thread post %s as deleted", postID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// updateThreadAfterReplyDeletion decrements the thread reply count and adjusts the participants
|
|
// list as necessary.
|
|
func (s *SqlPostStore) updateThreadAfterReplyDeletion(transaction *sqlxTxWrapper, rootId string, userId string) error {
|
|
if rootId != "" {
|
|
queryString, args, err := s.getQueryBuilder().
|
|
Select("COUNT(Posts.Id)").
|
|
From("Posts").
|
|
Where(sq.And{
|
|
sq.Eq{"Posts.RootId": rootId},
|
|
sq.Eq{"Posts.UserId": userId},
|
|
sq.Eq{"Posts.DeleteAt": 0},
|
|
}).
|
|
ToSql()
|
|
|
|
if err != nil {
|
|
return errors.Wrap(err, "failed to create SQL query to count user's posts")
|
|
}
|
|
|
|
var count int64
|
|
err = transaction.Get(&count, queryString, args...)
|
|
|
|
if err != nil {
|
|
return errors.Wrap(err, "failed to count user's posts in thread")
|
|
}
|
|
|
|
// Updating replyCount, and reducing participants if this was the last post in the thread for the user
|
|
updateQuery := s.getQueryBuilder().Update("Threads")
|
|
|
|
if count == 0 {
|
|
if s.DriverName() == model.DatabaseDriverPostgres {
|
|
updateQuery = updateQuery.Set("Participants", sq.Expr("Participants - ?", userId))
|
|
} else {
|
|
updateQuery = updateQuery.
|
|
Set("Participants", sq.Expr(
|
|
`IFNULL(JSON_REMOVE(Participants, JSON_UNQUOTE(JSON_SEARCH(Participants, 'one', ?))), Participants)`, userId,
|
|
))
|
|
}
|
|
}
|
|
|
|
lastReplyAtSubquery := sq.Select("COALESCE(MAX(CreateAt), 0)").
|
|
From("Posts").
|
|
Where(sq.Eq{
|
|
"RootId": rootId,
|
|
"DeleteAt": 0,
|
|
})
|
|
|
|
lastReplyCountSubquery := sq.Select("Count(*)").
|
|
From("Posts").
|
|
Where(sq.Eq{
|
|
"RootId": rootId,
|
|
"DeleteAt": 0,
|
|
})
|
|
|
|
updateQueryString, updateArgs, err := updateQuery.
|
|
Set("LastReplyAt", lastReplyAtSubquery).
|
|
Set("ReplyCount", lastReplyCountSubquery).
|
|
Where(sq.And{
|
|
sq.Eq{"PostId": rootId},
|
|
sq.Gt{"ReplyCount": 0},
|
|
}).
|
|
ToSql()
|
|
|
|
if err != nil {
|
|
return errors.Wrap(err, "failed to create SQL query to update thread")
|
|
}
|
|
|
|
_, err = transaction.Exec(updateQueryString, updateArgs...)
|
|
|
|
if err != nil {
|
|
return errors.Wrap(err, "failed to update Threads")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SqlPostStore) savePostsPriority(transaction *sqlxTxWrapper, posts []*model.Post) error {
|
|
for _, post := range posts {
|
|
if post.GetPriority() != nil {
|
|
postPriority := &model.PostPriority{
|
|
PostId: post.Id,
|
|
ChannelId: post.ChannelId,
|
|
Priority: post.Metadata.Priority.Priority,
|
|
RequestedAck: post.Metadata.Priority.RequestedAck,
|
|
PersistentNotifications: post.Metadata.Priority.PersistentNotifications,
|
|
}
|
|
if _, err := transaction.NamedExec(`INSERT INTO PostsPriority (PostId, ChannelId, Priority, RequestedAck, PersistentNotifications) VALUES (:PostId, :ChannelId, :Priority, :RequestedAck, :PersistentNotifications)`, postPriority); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SqlPostStore) savePostsPersistentNotifications(transaction *sqlxTxWrapper, posts []*model.Post) error {
|
|
for _, post := range posts {
|
|
if priority := post.GetPriority(); priority != nil && priority.PersistentNotifications != nil && *priority.PersistentNotifications {
|
|
if _, err := transaction.NamedExec(`INSERT INTO PersistentNotifications (PostId, CreateAt, LastSentAt, DeleteAt, SentCount) VALUES (:PostId, :CreateAt, :LastSentAt, :DeleteAt, :SentCount)`, &model.PostPersistentNotifications{
|
|
PostId: post.Id,
|
|
CreateAt: post.CreateAt,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SqlPostStore) updateThreadsFromPosts(transaction *sqlxTxWrapper, posts []*model.Post) error {
|
|
postsByRoot := map[string][]*model.Post{}
|
|
var rootIds []string
|
|
for _, post := range posts {
|
|
// skip if post is not a part of a thread
|
|
if post.RootId == "" {
|
|
continue
|
|
}
|
|
rootIds = append(rootIds, post.RootId)
|
|
postsByRoot[post.RootId] = append(postsByRoot[post.RootId], post)
|
|
}
|
|
if len(rootIds) == 0 {
|
|
return nil
|
|
}
|
|
threadsByRootsSql, threadsByRootsArgs, err := s.getQueryBuilder().
|
|
Select(
|
|
"Threads.PostId",
|
|
"Threads.ChannelId",
|
|
"Threads.ReplyCount",
|
|
"Threads.LastReplyAt",
|
|
"Threads.Participants",
|
|
"COALESCE(Threads.ThreadDeleteAt, 0) AS DeleteAt",
|
|
).
|
|
From("Threads").
|
|
Where(sq.Eq{"Threads.PostId": rootIds}).
|
|
ToSql()
|
|
if err != nil {
|
|
return errors.Wrap(err, "updateThreadsFromPosts_ToSql")
|
|
}
|
|
|
|
threadsByRoots := []*model.Thread{}
|
|
err = transaction.Select(&threadsByRoots, threadsByRootsSql, threadsByRootsArgs...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
threadByRoot := map[string]*model.Thread{}
|
|
for _, thread := range threadsByRoots {
|
|
threadByRoot[thread.PostId] = thread
|
|
}
|
|
|
|
teamIdByChannelId := map[string]string{}
|
|
|
|
for rootId, posts := range postsByRoot {
|
|
if thread, found := threadByRoot[rootId]; !found {
|
|
data := []struct {
|
|
UserId string
|
|
RepliedAt int64
|
|
}{}
|
|
|
|
// calculate participants
|
|
if err := transaction.Select(&data, "SELECT Posts.UserId, MAX(Posts.CreateAt) as RepliedAt FROM Posts WHERE Posts.RootId=? AND Posts.DeleteAt=0 GROUP BY Posts.UserId ORDER BY RepliedAt ASC", rootId); err != nil {
|
|
return err
|
|
}
|
|
|
|
var participants model.StringArray
|
|
for _, item := range data {
|
|
participants = append(participants, item.UserId)
|
|
}
|
|
|
|
// calculate reply count
|
|
var count int64
|
|
err := transaction.Get(&count, "SELECT COUNT(Posts.Id) FROM Posts WHERE Posts.RootId=? And Posts.DeleteAt=0", rootId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// calculate last reply at
|
|
var lastReplyAt int64
|
|
err = transaction.Get(&lastReplyAt, "SELECT COALESCE(MAX(Posts.CreateAt), 0) FROM Posts WHERE Posts.RootID=? and Posts.DeleteAt=0", rootId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
channelId := posts[0].ChannelId
|
|
teamId, ok := teamIdByChannelId[channelId]
|
|
if !ok {
|
|
// get teamId for channel
|
|
err = transaction.Get(&teamId, "SELECT COALESCE(Channels.TeamId, '') FROM Channels WHERE Channels.Id=?", channelId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// store teamId for channel for efficiency
|
|
teamIdByChannelId[channelId] = teamId
|
|
}
|
|
// no metadata entry, create one
|
|
if _, err := transaction.NamedExec(`INSERT INTO Threads
|
|
(PostId, ChannelId, ReplyCount, LastReplyAt, Participants, ThreadTeamId)
|
|
VALUES
|
|
(:PostId, :ChannelId, :ReplyCount, :LastReplyAt, :Participants, :TeamId)`, &model.Thread{
|
|
PostId: rootId,
|
|
ChannelId: channelId,
|
|
ReplyCount: count,
|
|
LastReplyAt: lastReplyAt,
|
|
Participants: participants,
|
|
TeamId: teamId,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
// metadata exists, update it
|
|
for _, post := range posts {
|
|
thread.ReplyCount += 1
|
|
if thread.Participants.Contains(post.UserId) {
|
|
thread.Participants = thread.Participants.Remove(post.UserId)
|
|
}
|
|
thread.Participants = append(thread.Participants, post.UserId)
|
|
if post.CreateAt > thread.LastReplyAt {
|
|
thread.LastReplyAt = post.CreateAt
|
|
}
|
|
}
|
|
if _, err := transaction.NamedExec(`UPDATE Threads
|
|
SET ChannelId = :ChannelId,
|
|
ReplyCount = :ReplyCount,
|
|
LastReplyAt = :LastReplyAt,
|
|
Participants = :Participants
|
|
WHERE PostId=:PostId`, thread); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SqlPostStore) SetPostReminder(reminder *model.PostReminder) error {
|
|
transaction, err := s.GetMaster().Beginx()
|
|
if err != nil {
|
|
return errors.Wrap(err, "begin_transaction")
|
|
}
|
|
defer finalizeTransactionX(transaction, &err)
|
|
|
|
sql := `SELECT EXISTS (SELECT 1 FROM Posts WHERE Id=?)`
|
|
var exist bool
|
|
err = transaction.Get(&exist, sql, reminder.PostId)
|
|
if err != nil {
|
|
return errors.Wrap(err, "failed to check for post")
|
|
}
|
|
if !exist {
|
|
return store.NewErrNotFound("Post", reminder.PostId)
|
|
}
|
|
|
|
query := s.getQueryBuilder().
|
|
Insert("PostReminders").
|
|
Columns("PostId", "UserId", "TargetTime").
|
|
Values(reminder.PostId, reminder.UserId, reminder.TargetTime).
|
|
SuffixExpr(sq.Expr("ON CONFLICT (postid, userid) DO UPDATE SET TargetTime = ?", reminder.TargetTime))
|
|
|
|
sql, args, err := query.ToSql()
|
|
if err != nil {
|
|
return errors.Wrap(err, "setPostReminder_tosql")
|
|
}
|
|
if _, err2 := transaction.Exec(sql, args...); err2 != nil {
|
|
return errors.Wrap(err2, "failed to insert post reminder")
|
|
}
|
|
if err = transaction.Commit(); err != nil {
|
|
return errors.Wrap(err, "commit_transaction")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SqlPostStore) GetPostReminders(now int64) (_ []*model.PostReminder, err error) {
|
|
reminders := []*model.PostReminder{}
|
|
|
|
transaction, err := s.GetMaster().Beginx()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "begin_transaction")
|
|
}
|
|
defer finalizeTransactionX(transaction, &err)
|
|
|
|
err = transaction.Select(&reminders, `SELECT PostId, UserId
|
|
FROM PostReminders
|
|
WHERE TargetTime <= ?`, now)
|
|
if err != nil && err != sql.ErrNoRows {
|
|
return nil, errors.Wrap(err, "failed to get post reminders")
|
|
}
|
|
|
|
if err == sql.ErrNoRows {
|
|
// No need to execute delete statement if there's nothing to delete.
|
|
return reminders, nil
|
|
}
|
|
|
|
// TODO: https://mattermost.atlassian.net/browse/MM-63368
|
|
// Postgres supports RETURNING * in a DELETE statement, but MySQL doesn't.
|
|
// So we are stuck with 2 queries. Not taking separate paths for Postgres
|
|
// and MySQL for simplicity.
|
|
_, err = transaction.Exec(`DELETE from PostReminders WHERE TargetTime <= ?`, now)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to delete post reminders")
|
|
}
|
|
|
|
if err = transaction.Commit(); err != nil {
|
|
return nil, errors.Wrap(err, "commit_transaction")
|
|
}
|
|
|
|
return reminders, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) DeleteAllPostRemindersForPost(postId string) error {
|
|
_, err := s.GetMaster().Exec(`DELETE from PostReminders WHERE PostId = ?`, postId)
|
|
if err != nil {
|
|
return errors.Wrapf(err, "failed to delete post reminders for postId %s", postId)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SqlPostStore) GetPostReminderMetadata(postID string) (*store.PostReminderMetadata, error) {
|
|
meta := &store.PostReminderMetadata{}
|
|
err := s.GetReplica().Get(meta, `SELECT c.id as ChannelID,
|
|
COALESCE(t.name, '') as TeamName,
|
|
u.locale as UserLocale, u.username as Username
|
|
FROM Posts p
|
|
JOIN Channels c ON p.ChannelId=c.Id
|
|
LEFT JOIN Teams t ON c.TeamId=t.Id
|
|
JOIN Users u ON p.UserId=u.Id
|
|
AND p.Id=?`, postID)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "failed to get post reminder metadata: postId %s", postID)
|
|
}
|
|
|
|
return meta, nil
|
|
}
|
|
|
|
func (s *SqlPostStore) RefreshPostStats() error {
|
|
if s.DriverName() == model.DatabaseDriverPostgres {
|
|
// CONCURRENTLY is not used deliberately because as per Postgres docs,
|
|
// not using CONCURRENTLY takes less resources and completes faster
|
|
// at the expense of locking the mat view. Since viewing admin console
|
|
// is not a very frequent activity, we accept the tradeoff to let the
|
|
// refresh happen as fast as possible.
|
|
if _, err := s.GetMaster().Exec("REFRESH MATERIALIZED VIEW posts_by_team_day"); err != nil {
|
|
return errors.Wrap(err, "error refreshing materialized view posts_by_team_day")
|
|
}
|
|
|
|
if _, err := s.GetMaster().Exec("REFRESH MATERIALIZED VIEW bot_posts_by_team_day"); err != nil {
|
|
return errors.Wrap(err, "error refreshing materialized view bot_posts_by_team_day")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// RestoreContentFlaggedPost restores a flagged post along with all its replies and associated files.
|
|
// When restoring replies, it does not restore posts that were intentionally deleted by a user, and
|
|
// it only restores posts deleted by the specified deletedBy user ID, which in case of Content Flagging is
|
|
// the Content Reviewer bot.
|
|
func (s *SqlPostStore) RestoreContentFlaggedPost(flaggedPost *model.Post, statusFieldId, contentFlaggingManagedFieldId string) error {
|
|
tx, err := s.GetMaster().Beginx()
|
|
if err != nil {
|
|
return errors.Wrap(err, "begin_transaction")
|
|
}
|
|
defer finalizeTransactionX(tx, &err)
|
|
|
|
baseSubQuery := s.getSubQueryBuilder().
|
|
Select("p.Id as PostId").
|
|
From("Posts as p").
|
|
InnerJoin("PropertyValues as pv_managed ON pv_managed.TargetId = p.Id AND pv_managed.FieldId = ? AND pv_managed.DeleteAt = 0 AND pv_managed.Value = 'true'", contentFlaggingManagedFieldId).
|
|
LeftJoin("PropertyValues as pv_status ON pv_status.TargetId = p.Id AND pv_status.FieldId = ? AND pv_status.DeleteAt = 0", statusFieldId)
|
|
|
|
err = s.restoreContentFlaggedRootPost(tx, baseSubQuery, flaggedPost.Id, contentFlaggingManagedFieldId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if flaggedPost.RootId == "" {
|
|
err = s.restoreContentFlaggedPostReplies(tx, baseSubQuery, flaggedPost.Id, contentFlaggingManagedFieldId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
err = s.updateThreadsFromPosts(tx, []*model.Post{flaggedPost})
|
|
if err != nil {
|
|
return errors.Wrapf(err, "SqlPostStore.RestoreContentFlaggedPost: failed to update thread for flaggedPost %s", flaggedPost.Id)
|
|
}
|
|
}
|
|
|
|
err = s.removeContentFlaggingManagedPropertyValues(tx, baseSubQuery, flaggedPost.Id, contentFlaggingManagedFieldId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = tx.Commit(); err != nil {
|
|
return errors.Wrap(err, "commit_transaction")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *SqlPostStore) restoreContentFlaggedRootPost(tx *sqlxTxWrapper, baseSubQuery sq.SelectBuilder, postId, contentFlaggingManagedFieldId string) error {
|
|
postIdSubQuery := baseSubQuery.
|
|
Where(sq.Eq{"p.Id": postId}).
|
|
Where(sq.Or{
|
|
sq.Eq{"pv_status.value": fmt.Sprintf("\"%s\"", model.ContentFlaggingStatusPending)},
|
|
sq.Eq{"pv_status.value": fmt.Sprintf("\"%s\"", model.ContentFlaggingStatusAssigned)},
|
|
})
|
|
|
|
// Restoring the post
|
|
queryBuilder := s.getQueryBuilder().
|
|
Update("Posts").
|
|
Set("DeleteAt", 0).
|
|
Where(sq.Expr("Id IN (?)", postIdSubQuery.Where(sq.NotEq{"p.DeleteAt": 0})))
|
|
|
|
if _, err := tx.ExecBuilder(queryBuilder); err != nil {
|
|
return errors.Wrapf(err, "SqlPostStore.RestoreContentFlaggedPost: failed to restore post %s", postId)
|
|
}
|
|
|
|
return s.restoreFilesForSubQuery(tx, postIdSubQuery)
|
|
}
|
|
|
|
func (s *SqlPostStore) restoreContentFlaggedPostReplies(tx *sqlxTxWrapper, baseSubQuery sq.SelectBuilder, rootPostId, contentFlaggingManagedFieldId string) error {
|
|
postIdSubQuery := baseSubQuery.
|
|
Where(sq.Eq{"p.RootId": rootPostId}).
|
|
Where(sq.NotEq{"p.DeleteAt": 0}).
|
|
Where(sq.Or{
|
|
sq.Expr("pv_status.id IS NULL"),
|
|
sq.Eq{"pv_status.value": fmt.Sprintf("\"%s\"", model.ContentFlaggingStatusRetained)},
|
|
})
|
|
|
|
queryBuilder := s.getQueryBuilder().
|
|
Update("Posts").
|
|
Set("DeleteAt", 0).
|
|
Where(sq.Expr("Id IN (?)", postIdSubQuery))
|
|
|
|
result, err := tx.ExecBuilder(queryBuilder)
|
|
if err != nil {
|
|
return errors.Wrapf(err, "SqlPostStore.RestoreContentFlaggedPost: failed to restore flaggedPost replies %s", rootPostId)
|
|
}
|
|
|
|
rowsAffected, err := result.RowsAffected()
|
|
if err != nil {
|
|
return errors.Wrapf(err, "SqlPostStore.RestoreContentFlaggedPost: failed to get rows affected for restored flaggedPost replies %s", rootPostId)
|
|
}
|
|
|
|
if rowsAffected > 0 {
|
|
if err := s.restoreFilesForSubQuery(tx, postIdSubQuery); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *SqlPostStore) removeContentFlaggingManagedPropertyValues(tx *sqlxTxWrapper, baseSubQuery sq.SelectBuilder, rootId, contentFlaggingManagedFieldId string) error {
|
|
postIdSubQuery := baseSubQuery.
|
|
Where(sq.Or{
|
|
sq.Eq{"p.Id": rootId},
|
|
sq.Eq{"p.RootId": rootId},
|
|
}).
|
|
Where(sq.Or{
|
|
sq.Eq{"p.Id": rootId},
|
|
sq.Expr("pv_status.value IS NULL"),
|
|
}).
|
|
Where(sq.Eq{"p.DeleteAt": 0})
|
|
|
|
queryBuilder := s.getQueryBuilder().
|
|
Update("PropertyValues").
|
|
Set("DeleteAt", model.GetMillis()).
|
|
Where(sq.Eq{"FieldId": contentFlaggingManagedFieldId}).
|
|
Where(sq.Expr("TargetId IN (?)", postIdSubQuery))
|
|
|
|
_, err := tx.ExecBuilder(queryBuilder)
|
|
return err
|
|
}
|
|
|
|
func (s *SqlPostStore) restoreFilesForSubQuery(tx *sqlxTxWrapper, postIdSubQuery sq.SelectBuilder) error {
|
|
queryBuilder := s.getQueryBuilder().
|
|
Update("FileInfo").
|
|
Set("DeleteAt", 0).
|
|
Where(sq.Expr("FileInfo.PostId IN (?)", postIdSubQuery))
|
|
|
|
_, err := tx.ExecBuilder(queryBuilder)
|
|
return err
|
|
}
|