snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,980 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"bytes"
"database/sql"
"encoding/json"
"fmt"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/einterfaces"
"github.com/pkg/errors"
sq "github.com/mattermost/squirrel"
)
const MaxPerPage = 1000
const DefaultPerPage = 10
// Usually rules are how we define the policy, hence the versioning. For v0.1, we also
// have the imports field which is used to link with the parent policy.
type accessControlPolicyV0_1 struct {
Imports []string `json:"imports"`
Rules []model.AccessControlPolicyRule `json:"rules"`
Roles []string `json:"roles,omitempty"`
Scope string `json:"scope,omitempty"`
ScopeID string `json:"scope_id,omitempty"`
}
// These are the fields that meant to be unchanged with the policy versions.
type storeAccessControlPolicy struct {
ID string
Name string
Type string
Active bool
CreateAt int64
Revision int
Version string
Data []byte
Props []byte
}
// This needs to be updated with the new version of the policy.
// with the new name as this only supports v0.1
func (s *storeAccessControlPolicy) toModel() (*model.AccessControlPolicy, error) {
policy := &model.AccessControlPolicy{
ID: s.ID,
Name: s.Name,
Type: s.Type,
Active: s.Active,
CreateAt: s.CreateAt,
Revision: s.Revision,
Version: s.Version,
}
if len(s.Data) > 0 {
var p accessControlPolicyV0_1
if err := json.Unmarshal(s.Data, &p); err != nil {
return nil, err
}
policy.Imports = p.Imports
policy.Rules = p.Rules
policy.Roles = p.Roles
policy.Scope = p.Scope
policy.ScopeID = p.ScopeID
}
if len(s.Props) > 0 {
if err := json.Unmarshal(s.Props, &policy.Props); err != nil {
return nil, err
}
}
return policy, nil
}
func fromModel(policy *model.AccessControlPolicy) (*storeAccessControlPolicy, error) {
imports := policy.Imports
if imports == nil {
imports = []string{}
}
data, err := json.Marshal(&accessControlPolicyV0_1{
Imports: imports,
Rules: policy.Rules,
Roles: policy.Roles,
Scope: policy.Scope,
ScopeID: policy.ScopeID,
})
if err != nil {
return nil, err
}
props, err := json.Marshal(policy.Props)
if err != nil {
return nil, err
}
return &storeAccessControlPolicy{
ID: policy.ID,
Name: policy.Name,
Type: policy.Type,
Active: policy.Active,
CreateAt: policy.CreateAt,
Revision: policy.Revision,
Version: policy.Version,
Data: data,
Props: props,
}, nil
}
func accessControlPolicySliceColumns(prefix ...string) []string {
var p string
if len(prefix) == 1 {
p = prefix[0] + "."
} else if len(prefix) > 1 {
panic("cannot accept multiple prefixes")
}
return []string{
p + "ID",
p + "Name",
p + "Type",
p + "Active",
p + "CreateAt",
p + "Revision",
p + "Version",
p + "Data",
p + "Props",
}
}
func accessControlPolicyHistorySliceColumns(prefix ...string) []string {
var p string
if len(prefix) == 1 {
p = prefix[0] + "."
} else if len(prefix) > 1 {
panic("cannot accept multiple prefixes")
}
return []string{
p + "ID",
p + "Name",
p + "Type",
p + "CreateAt",
p + "Revision",
p + "Version",
p + "Data",
p + "Props",
}
}
type SqlAccessControlPolicyStore struct {
*SqlStore
metrics einterfaces.MetricsInterface
selectQueryBuilder sq.SelectBuilder
}
// newSqlAccessControlPolicyStore creates an instance of AccessControlPolicyStorea.
func newSqlAccessControlPolicyStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterface) store.AccessControlPolicyStore {
s := &SqlAccessControlPolicyStore{
SqlStore: sqlStore,
metrics: metrics,
}
s.selectQueryBuilder = s.getQueryBuilder().Select(accessControlPolicySliceColumns()...).From("AccessControlPolicies")
return s
}
func preSaveAccessControlPolicy(policy *storeAccessControlPolicy, existingPolicy *model.AccessControlPolicy) {
// since policies are immutable, we need to create a new revision
// also if it's going to be saved, eventually it will be the new one
// we overwrite createAt to make sure it gets the correct timestamp before saving
// if there is no existing policy, we set the revision to 1
policy.CreateAt = model.GetMillis()
if existingPolicy != nil {
policy.Revision = existingPolicy.Revision + 1
} else {
policy.Revision = 1
}
}
func (s *SqlAccessControlPolicyStore) Save(rctx request.CTX, policy *model.AccessControlPolicy) (*model.AccessControlPolicy, error) {
if err := policy.IsValid(); err != nil {
return nil, err
}
tx, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "failed to start transaction")
}
defer finalizeTransactionX(tx, &err)
existingPolicy, err := s.getT(rctx, tx, policy.ID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, errors.Wrapf(err, "failed to fetch policy with id=%s", policy.ID)
}
storePolicy, err := fromModel(policy)
if err != nil {
return nil, errors.Wrapf(err, "failed to parse policy with Id=%s", policy.ID)
}
data := storePolicy.Data
props := storePolicy.Props
if s.IsBinaryParamEnabled() {
data = AppendBinaryFlag(data)
props = AppendBinaryFlag(props)
}
if existingPolicy != nil {
if existingPolicy.Type != policy.Type {
return nil, errors.New("cannot change type of existing policy")
}
// move existing policy to history
tmp, err2 := fromModel(existingPolicy)
if err2 != nil {
return nil, errors.Wrapf(err2, "failed to parse policy with id=%s", policy.ID)
}
// Check if the policy has actually changed
// We compare data and version fields. Name changes are cosmetic
// and should not create a new revision in history.
if bytes.Equal(storePolicy.Data, tmp.Data) &&
storePolicy.Version == tmp.Version {
// Update name in place if it changed, without bumping revision
if storePolicy.Name != tmp.Name {
nameUpdate := s.getQueryBuilder().
Update("AccessControlPolicies").
Set("Name", storePolicy.Name).
Where(sq.Eq{"ID": policy.ID})
if _, err = tx.ExecBuilder(nameUpdate); err != nil {
if IsUniqueConstraintError(err, []string{"Name", "idx_accesscontrolpolicies_name_type"}) {
return nil, store.NewErrConflict("AccessControlPolicy", err, "name="+policy.Name)
}
return nil, errors.Wrapf(err, "failed to update name for policy with id=%s", policy.ID)
}
existingPolicy.Name = storePolicy.Name
if err = tx.Commit(); err != nil {
return nil, errors.Wrap(err, "commit_transaction")
}
}
return existingPolicy, nil
}
existingData := tmp.Data
existingProps := tmp.Props
if s.IsBinaryParamEnabled() {
existingData = AppendBinaryFlag(existingData)
existingProps = AppendBinaryFlag(existingProps)
}
query := s.getQueryBuilder().
Insert("AccessControlPolicyHistory").
Columns(accessControlPolicyHistorySliceColumns()...).
Values(tmp.ID, tmp.Name, tmp.Type, tmp.CreateAt, tmp.Revision, tmp.Version, existingData, existingProps)
_, err = tx.ExecBuilder(query)
if err != nil {
return nil, errors.Wrapf(err, "failed to save policy with id=%s to history", policy.ID)
}
err = s.deleteT(rctx, tx, existingPolicy.ID)
if err != nil {
return nil, errors.Wrapf(err, "failed to delete policy with id=%s", policy.ID)
}
} else {
// if there is no existing policy, also check the history table
// to make sure we are not overwriting an existing policy
existingPolicy, err = s.getHistoryT(rctx, tx, policy.ID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, errors.Wrapf(err, "failed to fetch policy with id=%s", policy.ID)
}
}
preSaveAccessControlPolicy(storePolicy, existingPolicy)
query := s.getQueryBuilder().
Insert("AccessControlPolicies").
Columns(accessControlPolicySliceColumns()...).
Values(storePolicy.ID, storePolicy.Name, storePolicy.Type, storePolicy.Active, storePolicy.CreateAt, storePolicy.Revision, storePolicy.Version, data, props)
_, err = tx.ExecBuilder(query)
if err != nil {
if IsUniqueConstraintError(err, []string{"Name", "idx_accesscontrolpolicies_name_type"}) {
return nil, store.NewErrConflict("AccessControlPolicy", err, "name="+policy.Name)
}
return nil, errors.Wrapf(err, "failed to save policy with id=%s", policy.ID)
}
cp, err := storePolicy.toModel()
if err != nil {
return nil, errors.Wrapf(err, "failed to parse policy with id=%s", policy.ID)
}
if err = tx.Commit(); err != nil {
return nil, errors.Wrap(err, "commit_transaction")
}
return cp, nil
}
func (s *SqlAccessControlPolicyStore) Delete(rctx request.CTX, id string) error {
tx, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "failed to start transaction")
}
defer finalizeTransactionX(tx, &err)
existingPolicy, err := s.getT(rctx, tx, id)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return errors.Wrapf(err, "failed to fetch policy with id=%s", id)
}
if existingPolicy != nil {
tmp, err2 := fromModel(existingPolicy)
if err2 != nil {
return errors.Wrapf(err2, "failed to parse policy with id=%s", id)
}
data := tmp.Data
props := tmp.Props
if s.IsBinaryParamEnabled() {
data = AppendBinaryFlag(data)
props = AppendBinaryFlag(props)
}
query := s.getQueryBuilder().
Insert("AccessControlPolicyHistory").
Columns(accessControlPolicyHistorySliceColumns()...).
Values(tmp.ID, tmp.Name, tmp.Type, tmp.CreateAt, tmp.Revision, tmp.Version, data, props)
_, err = tx.ExecBuilder(query)
if err != nil {
return errors.Wrapf(err, "failed to save policy with id=%s to history", id)
}
err = s.deleteT(rctx, tx, existingPolicy.ID)
if err != nil {
return errors.Wrapf(err, "failed to delete policy with id=%s", id)
}
}
if err = tx.Commit(); err != nil {
return errors.Wrap(err, "commit_transaction")
}
return nil
}
func (s *SqlAccessControlPolicyStore) deleteT(_ request.CTX, tx *sqlxTxWrapper, id string) error {
query := s.getQueryBuilder().Delete("AccessControlPolicies").Where(sq.Eq{"ID": id})
_, err := tx.ExecBuilder(query)
if err != nil {
return errors.Wrapf(err, "failed to delete policy with id=%s", id)
}
return nil
}
func (s *SqlAccessControlPolicyStore) SetActiveStatus(rctx request.CTX, id string, active bool) (*model.AccessControlPolicy, error) {
tx, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "failed to start transaction")
}
defer finalizeTransactionX(tx, &err)
existingPolicy, err := s.getT(rctx, tx, id)
if err != nil {
return nil, errors.Wrapf(err, "failed to fetch policy with id=%s", id)
} else if errors.Is(err, sql.ErrNoRows) {
return nil, store.NewErrNotFound("AccessControlPolicy", id)
}
// also make sure if the policy is valid before updating active status
// just in case
existingPolicy.Active = active
if appErr := existingPolicy.IsValid(); err != nil {
return nil, appErr
}
query, args, err := s.getQueryBuilder().Update("AccessControlPolicies").Set("Active", active).Where(sq.Eq{"ID": id}).ToSql()
if err != nil {
return nil, errors.Wrapf(err, "failed to build query for policy with id=%s", id)
}
_, err = tx.Exec(query, args...)
if err != nil {
return nil, errors.Wrapf(err, "failed to update policy with id=%s", id)
}
if existingPolicy.Type == model.AccessControlPolicyTypeParent {
// if the policy is a parent, we need to update the child policies
expr := sq.Expr("Data->'imports' @> ?::jsonb", fmt.Sprintf("%q", id))
query, args, err = s.getQueryBuilder().Update("AccessControlPolicies").Set("Active", active).Where(expr).ToSql()
if err != nil {
return nil, errors.Wrapf(err, "failed to build query for policy with id=%s", id)
}
_, err = tx.Exec(query, args...)
if err != nil {
return nil, errors.Wrapf(err, "failed to update child policies with id=%s", id)
}
}
if err = tx.Commit(); err != nil {
return nil, errors.Wrap(err, "commit_transaction")
}
return existingPolicy, nil
}
func (s *SqlAccessControlPolicyStore) SetActiveStatusMultiple(rctx request.CTX, list []model.AccessControlPolicyActiveUpdate) ([]*model.AccessControlPolicy, error) {
tx, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "failed to start transaction")
}
defer finalizeTransactionX(tx, &err)
// Group by active status for batch updates
activeTrue := []string{}
activeFalse := []string{}
ids := make([]any, 0, len(list))
for _, entry := range list {
ids = append(ids, entry.ID)
if entry.Active {
activeTrue = append(activeTrue, entry.ID)
continue
}
activeFalse = append(activeFalse, entry.ID)
}
// Update active=true policies
if len(activeTrue) > 0 {
query, args, qbErr := s.getQueryBuilder().
Update("AccessControlPolicies").
Set("Active", true).
Where(sq.Eq{"ID": activeTrue}).
ToSql()
if qbErr != nil {
return nil, errors.Wrap(qbErr, "failed to build active=true update query")
}
_, err = tx.Exec(query, args...)
if err != nil {
return nil, errors.Wrap(err, "failed to update active=true policies")
}
}
// Update active=false policies
if len(activeFalse) > 0 {
query, args, qbErr := s.getQueryBuilder().
Update("AccessControlPolicies").
Set("Active", false).
Where(sq.Eq{"ID": activeFalse}).
ToSql()
if qbErr != nil {
return nil, errors.Wrap(qbErr, "failed to build active=false update query")
}
_, err = tx.Exec(query, args...)
if err != nil {
return nil, errors.Wrap(err, "failed to update active=false policies")
}
}
p := []storeAccessControlPolicy{}
query := s.selectQueryBuilder.Where(sq.Eq{"ID": ids})
err = tx.SelectBuilder(&p, query)
if err != nil {
return nil, errors.Wrapf(err, "failed to find policies with ids=%v", ids)
}
policies := make([]*model.AccessControlPolicy, len(p))
for i := range p {
policies[i], err = p[i].toModel()
if err != nil {
return nil, errors.Wrapf(err, "failed to parse policy with id=%s", p[i].ID)
}
}
if err = tx.Commit(); err != nil {
return nil, errors.Wrap(err, "commit_transaction")
}
return policies, nil
}
func (s *SqlAccessControlPolicyStore) Get(_ request.CTX, id string) (*model.AccessControlPolicy, error) {
p := storeAccessControlPolicy{}
query := s.selectQueryBuilder.Where(sq.Eq{"ID": id})
err := s.GetMaster().GetBuilder(&p, query)
if err != nil {
if err == sql.ErrNoRows {
return nil, store.NewErrNotFound("AccessControlPolicy", id)
}
return nil, errors.Wrapf(err, "failed to find policy with id=%s", id)
}
policy, err := p.toModel()
if err != nil {
return nil, errors.Wrapf(err, "failed to parse policy with id=%s", id)
}
return policy, nil
}
func (s *SqlAccessControlPolicyStore) getT(_ request.CTX, tx *sqlxTxWrapper, id string) (*model.AccessControlPolicy, error) {
query := s.getQueryBuilder().
Select(accessControlPolicySliceColumns()...).
From("AccessControlPolicies").
Where(
sq.Eq{"ID": id},
)
sql, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrapf(err, "failed to build query for policy with id=%s", id)
}
var storePolicy storeAccessControlPolicy
err = tx.Get(&storePolicy, sql, args...)
if err != nil {
return nil, err
}
policy, err := storePolicy.toModel()
if err != nil {
return nil, errors.Wrapf(err, "failed to parse policy with id=%s", id)
}
return policy, nil
}
func (s *SqlAccessControlPolicyStore) getHistoryT(_ request.CTX, tx *sqlxTxWrapper, id string) (*model.AccessControlPolicy, error) {
query := s.getQueryBuilder().
Select(accessControlPolicyHistorySliceColumns()...).
From("AccessControlPolicyHistory").
Where(
sq.Eq{"ID": id},
).OrderBy("Revision DESC").
Limit(1)
sql, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrapf(err, "failed to build query for policy with id=%s", id)
}
var storePolicy storeAccessControlPolicy
err = tx.Get(&storePolicy, sql, args...)
if err != nil {
return nil, err
}
policy, err := storePolicy.toModel()
if err != nil {
return nil, errors.Wrapf(err, "failed to parse policy with id=%s", id)
}
return policy, nil
}
func (s *SqlAccessControlPolicyStore) GetAll(_ request.CTX, opts model.GetAccessControlPolicyOptions) ([]*model.AccessControlPolicy, model.AccessControlPolicyCursor, error) {
p := []storeAccessControlPolicy{}
query := s.selectQueryBuilder
if opts.ParentID != "" {
query = query.Where(sq.Expr("Data->'imports' @> ?", fmt.Sprintf("%q", opts.ParentID)))
}
if opts.Type != "" {
query = query.Where(sq.Eq{"Type": opts.Type})
}
cursor := opts.Cursor
if !cursor.IsEmpty() {
query = query.Where(sq.Or{
sq.Gt{"Id": cursor.ID},
})
}
limit := uint64(opts.Limit)
if limit < 1 {
limit = DefaultPerPage
} else if limit > MaxPerPage {
limit = MaxPerPage
}
query = query.Limit(limit)
err := s.GetReplica().SelectBuilder(&p, query)
if err != nil {
return nil, cursor, errors.Wrapf(err, "failed to find policies with opts={\"parentID\"=%q, \"resourceType\"=%q", opts.ParentID, opts.Type)
}
policies := make([]*model.AccessControlPolicy, len(p))
for i := range p {
policies[i], err = p[i].toModel()
if err != nil {
return nil, cursor, errors.Wrapf(err, "failed to parse policy with id=%s", p[i].ID)
}
}
if len(policies) != 0 {
cursor.ID = policies[len(policies)-1].ID
}
return policies, cursor, nil
}
func (s *SqlAccessControlPolicyStore) SearchPolicies(rctx request.CTX, opts model.AccessControlPolicySearch) ([]*model.AccessControlPolicy, int64, error) {
type wrapper struct {
storeAccessControlPolicy
ChildIDs json.RawMessage
ChannelCount int
TeamCount int
}
p := []wrapper{}
var query sq.SelectBuilder
if opts.IncludeChildren && opts.ParentID == "" {
columns := accessControlPolicySliceColumns("p")
childIDs := `COALESCE((SELECT JSON_AGG(c.ID)
FROM AccessControlPolicies c
WHERE c.Type != 'parent'
AND c.Data->'imports' @> JSONB_BUILD_ARRAY(p.ID)), '[]'::json) AS ChildIDs`
channelCount := `COALESCE((SELECT COUNT(*)
FROM AccessControlPolicies c
WHERE c.Type = 'channel'
AND c.Data->'imports' @> JSONB_BUILD_ARRAY(p.ID)), 0) AS ChannelCount`
teamCount := `COALESCE((SELECT COUNT(*)
FROM AccessControlPolicies c
WHERE c.Type = 'team'
AND c.Data->'imports' @> JSONB_BUILD_ARRAY(p.ID)), 0) AS TeamCount`
columns = append(columns, childIDs, channelCount, teamCount)
query = s.getQueryBuilder().Select(columns...).From("AccessControlPolicies p")
} else {
query = s.selectQueryBuilder
}
count := s.getQueryBuilder().Select("COUNT(*)").From("AccessControlPolicies")
if opts.Term != "" {
safeTerm := sanitizeSearchTerm(opts.Term, "*")
condition := sq.Expr("LOWER(Name) LIKE LOWER(?) ESCAPE '*'", fmt.Sprintf("%%%s%%", safeTerm))
query = query.Where(condition)
count = count.Where(condition)
}
if opts.Type != "" {
condition := sq.Eq{"Type": opts.Type}
query = query.Where(condition)
count = count.Where(condition)
}
if opts.ParentID != "" {
condition := sq.Expr("Data->'imports' @> ?", fmt.Sprintf("%q", opts.ParentID))
query = query.Where(condition)
count = count.Where(condition)
}
if len(opts.Actions) > 0 {
or := sq.Or{}
for _, action := range opts.Actions {
or = append(or, sq.Expr(
"EXISTS (SELECT 1 FROM jsonb_array_elements(CASE WHEN jsonb_typeof(Data->'rules') = 'array' THEN Data->'rules' ELSE '[]'::jsonb END) AS rule WHERE rule->'actions' @> ?::jsonb)",
fmt.Sprintf("%q", action),
))
}
query = query.Where(or)
count = count.Where(or)
}
if opts.Active {
query = query.Where(sq.Eq{"Active": true})
count = count.Where(sq.Eq{"Active": true})
}
if len(opts.IDs) > 0 {
condition := sq.Eq{"Id": opts.IDs}
query = query.Where(condition)
count = count.Where(condition)
}
// Scope/ScopeID filter policies by their persisted scope in the Data JSONB.
// Both must be provided together — a partial match is not meaningful.
if opts.Scope != "" && opts.ScopeID != "" {
scopeCondition := sq.And{
sq.Expr("Data->>'scope' = ?", opts.Scope),
sq.Expr("Data->>'scope_id' = ?", opts.ScopeID),
}
query = query.Where(scopeCondition)
count = count.Where(scopeCondition)
}
// TeamID filters to parent policies whose assigned channels ALL belong to the
// specified team. Policies with channels spanning multiple teams will NOT match
// even if one channel is in the requested team (COUNT(DISTINCT TeamId) = 1
// enforces single-team scope). This only applies to parent policies; the type
// is forced to 'parent' when TeamID is set.
if opts.TeamID != "" {
if opts.Type == model.AccessControlPolicyTypeChannel {
condition := sq.Expr(`Id IN (SELECT Id FROM Channels WHERE TeamId = ?)`, opts.TeamID)
query = query.Where(condition)
count = count.Where(condition)
} else {
if opts.Type == "" {
parentCondition := sq.Eq{"Type": model.AccessControlPolicyTypeParent}
query = query.Where(parentCondition)
count = count.Where(parentCondition)
}
condition := sq.Expr(`Id IN (
SELECT parent_id FROM (
SELECT ch.TeamId, jsonb_array_elements_text(COALESCE(NULLIF(cp.Data -> 'imports', 'null'::jsonb), '[]'::jsonb)) AS parent_id
FROM AccessControlPolicies cp
JOIN Channels ch ON ch.Id = cp.Id
WHERE cp.Type = 'channel'
) team_children
GROUP BY parent_id
HAVING COUNT(DISTINCT TeamId) = 1 AND MIN(TeamId) = ?)`, opts.TeamID)
query = query.Where(condition)
count = count.Where(condition)
}
}
cursor := opts.Cursor
if !cursor.IsEmpty() {
query = query.Where(sq.Gt{"Id": cursor.ID})
}
limit := uint64(opts.Limit)
if limit < 1 {
limit = DefaultPerPage
} else if limit > MaxPerPage {
limit = MaxPerPage
}
query = query.Limit(limit)
query = query.OrderBy("Id ASC")
err := s.GetReplica().SelectBuilder(&p, query)
if err != nil {
return nil, 0, errors.Wrapf(err, "failed to find policies with opts={\"name\"=%q, \"resourceType\"=%q", opts.Term, opts.Type)
}
policies := make([]*model.AccessControlPolicy, len(p))
for i := range p {
m, err2 := p[i].toModel()
if err2 != nil {
return nil, 0, errors.Wrapf(err2, "failed to parse policy with id=%s", p[i].ID)
}
// Props field is not guaranteed to be persisted correctly, and it shouldn't be.
// This is a field that we want to include metadata, some values may be stored but
// not all of them. For example for the childs, we don't want to update it whenever a
// child policy changes.
if opts.IncludeChildren && opts.ParentID == "" {
if m.Props == nil {
m.Props = make(map[string]any)
}
// Unmarshal the JSON array into a slice of strings
var childIDs []string
if err = json.Unmarshal(p[i].ChildIDs, &childIDs); err != nil {
return nil, 0, errors.Wrapf(err, "failed to unmarshal child IDs for policy with id=%s", p[i].ID)
}
m.Props["child_ids"] = childIDs
m.Props["channel_count"] = p[i].ChannelCount
m.Props["team_count"] = p[i].TeamCount
}
policies[i] = m
}
var total int64
err = s.GetReplica().GetBuilder(&total, count)
if err != nil {
return nil, 0, errors.Wrapf(err, "failed to count policies with opts={\"name\"=%q, \"resourceType\"=%q", opts.Term, opts.Type)
}
if len(policies) != 0 {
cursor.ID = policies[len(policies)-1].ID
}
return policies, total, nil
}
// actionsAggregationSQL is the per-policy action-union expression embedded in
// both GetActionsForPolicy and GetActionsForPolicies. It produces a JSONB
// object whose keys are the distinct action strings declared by the policy's
// own rules and the rules of any policies it imports. The expression is
// table-qualified with `p` so it can be reused as a correlated subquery
// against either a single row or a set of rows.
const actionsAggregationSQL = `COALESCE(
(
SELECT jsonb_object_agg(action, true)
FROM (
SELECT DISTINCT jsonb_array_elements_text(rule->'actions') AS action
FROM jsonb_array_elements(
CASE WHEN jsonb_typeof(p.Data->'rules') = 'array'
THEN p.Data->'rules' ELSE '[]'::jsonb END
) AS rule
UNION
SELECT DISTINCT jsonb_array_elements_text(rule->'actions') AS action
FROM AccessControlPolicies parent
JOIN jsonb_array_elements_text(
CASE WHEN jsonb_typeof(p.Data->'imports') = 'array'
THEN p.Data->'imports' ELSE '[]'::jsonb END
) AS imp(id) ON parent.ID = imp.id
CROSS JOIN LATERAL jsonb_array_elements(
CASE WHEN jsonb_typeof(parent.Data->'rules') = 'array'
THEN parent.Data->'rules' ELSE '[]'::jsonb END
) AS rule
) AS unioned_actions
),
'{}'::jsonb
)`
// GetActionsForPolicy returns the union of action keys declared by the policy's
// own rules and the rules of any policies it imports. The result is always a
// non-nil map (empty when the policy exists but declares no rules). Returns
// store.ErrNotFound when no AccessControlPolicies row exists for policyID.
//
// The query is bounded by a single row's expansion plus its (small) imports
// fan-out. Channels with no attached policy never reach this method because
// the App-layer hydrator short-circuits on PolicyEnforced=false.
func (s *SqlAccessControlPolicyStore) GetActionsForPolicy(_ request.CTX, policyID string) (map[string]bool, error) {
if !model.IsValidId(policyID) {
return nil, store.NewErrInvalidInput("AccessControlPolicy", "policyID", policyID)
}
var raw []byte
query := fmt.Sprintf(`SELECT %s AS actions FROM AccessControlPolicies p WHERE p.ID = $1`, actionsAggregationSQL)
err := s.GetReplica().Get(&raw, query, policyID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, store.NewErrNotFound("AccessControlPolicy", policyID)
}
return nil, errors.Wrapf(err, "failed to load policy actions for id=%s", policyID)
}
actions := map[string]bool{}
if len(raw) > 0 {
if err := json.Unmarshal(raw, &actions); err != nil {
return nil, errors.Wrapf(err, "failed to decode policy actions for id=%s", policyID)
}
}
return actions, nil
}
// GetActionsForPolicies returns the per-policy action union for each ID in
// policyIDs. Missing IDs are absent from the result map (callers can detect
// "policy not found" via `_, ok := result[id]`). An empty input slice returns
// an empty map and fires no SQL. Used by batched hydration on channel-list
// reads to avoid an N+1 against AccessControlPolicies.
func (s *SqlAccessControlPolicyStore) GetActionsForPolicies(_ request.CTX, policyIDs []string) (map[string]map[string]bool, error) {
if len(policyIDs) == 0 {
return map[string]map[string]bool{}, nil
}
for _, id := range policyIDs {
if !model.IsValidId(id) {
return nil, store.NewErrInvalidInput("AccessControlPolicy", "policyID", id)
}
}
query, args, err := s.getQueryBuilder().
Select("p.ID", fmt.Sprintf("%s AS actions", actionsAggregationSQL)).
From("AccessControlPolicies p").
Where(sq.Eq{"p.ID": policyIDs}).
ToSql()
if err != nil {
return nil, errors.Wrap(err, "failed to build batched policy actions query")
}
rows, err := s.GetReplica().Query(query, args...)
if err != nil {
return nil, errors.Wrap(err, "failed to load policy actions batch")
}
defer rows.Close()
result := make(map[string]map[string]bool, len(policyIDs))
for rows.Next() {
var id string
var raw []byte
if err := rows.Scan(&id, &raw); err != nil {
return nil, errors.Wrap(err, "failed to scan policy actions row")
}
actions := map[string]bool{}
if len(raw) > 0 {
if err := json.Unmarshal(raw, &actions); err != nil {
return nil, errors.Wrapf(err, "failed to decode policy actions for id=%s", id)
}
}
result[id] = actions
}
if err := rows.Err(); err != nil {
return nil, errors.Wrap(err, "policy actions row iteration failed")
}
return result, nil
}
// GetPoliciesByFieldID finds all policies whose CEL rule expressions reference the
// given property field ID. It performs a text search on the serialized JSONB Data
// column for the pattern "id_<fieldID>", which is how property fields are referenced
// in CEL expressions (e.g., user.attributes.id_<fieldID>).
//
// This is a sequential scan over the policies table. At the expected scale (hundreds
// to low thousands of admin-configured policies) this is well under 1ms.
// If the table grows beyond ~10K rows or this query lands on a hot path, a GIN
// trigram index on (Data::text) can accelerate it with no query changes.
func (s *SqlAccessControlPolicyStore) GetPoliciesByFieldID(_ request.CTX, fieldID string) ([]*model.AccessControlPolicy, error) {
if !model.IsValidId(fieldID) {
return nil, store.NewErrInvalidInput("AccessControlPolicy", "fieldID", fieldID)
}
p := []storeAccessControlPolicy{}
query := s.selectQueryBuilder.Where(sq.Expr("Data::text LIKE ?", fmt.Sprintf("%%id\\_%s%%", fieldID)))
err := s.GetReplica().SelectBuilder(&p, query)
if err != nil {
return nil, errors.Wrapf(err, "failed to find policies referencing field id=%s", fieldID)
}
policies := make([]*model.AccessControlPolicy, len(p))
for i := range p {
policies[i], err = p[i].toModel()
if err != nil {
return nil, errors.Wrapf(err, "failed to parse policy with id=%s", p[i].ID)
}
}
return policies, nil
}
// GetEtagEpoch covers the system-scoped permission policies plus the given channel's own policy
// row (a channel policy's ID equals the channel ID); an empty channelID covers only the former.
//
// MAX(CreateAt), because the table has no UpdateAt and saves are delete-and-reinsert. The count is
// folded in to catch deletes: dropping a policy that isn't the newest leaves the max untouched, so
// the ETag would still match and clients would keep a differently-sanitized cached copy.
//
// Matching the channel by primary key instead of scanning every policy's rules JSON costs a benign
// false positive: a membership-only change to this channel's policy also advances the epoch.
func (s *SqlAccessControlPolicyStore) GetEtagEpoch(rctx request.CTX, channelID string) (string, error) {
query, args, err := s.getQueryBuilder().
Select("COALESCE(MAX(CreateAt), 0) AS MaxCreateAt", "COUNT(*) AS Total").
From("AccessControlPolicies").
Where(sq.Or{
sq.Eq{"Type": model.AccessControlPolicyTypePermission},
sq.Eq{"Id": channelID},
}).
ToSql()
if err != nil {
return "", errors.Wrap(err, "GetEtagEpoch: failed to build query")
}
var epoch struct {
MaxCreateAt int64
Total int64
}
// Master, not replica — see the note on SqlAttributesStore.GetUserPropertyValuesEpoch.
if err := s.GetMaster().Get(&epoch, query, args...); err != nil {
return "", errors.Wrap(err, "GetEtagEpoch: query failed")
}
return fmt.Sprintf("%d-%d", epoch.MaxCreateAt, epoch.Total), nil
}
// No-op at the SQL layer; the render-ETag epoch is cached in and invalidated by the local cache layer.
func (s *SqlAccessControlPolicyStore) InvalidateEtagForChannel(channelID string) {}
func (s *SqlAccessControlPolicyStore) ClearEtagCache() {}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestAccessControlPolicyStore(t *testing.T) {
StoreTestWithSqlStore(t, storetest.TestAccessControlPolicyStore)
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"bytes"
"database/sql/driver"
"strconv"
)
type jsonArray []string
func (a jsonArray) Value() (driver.Value, error) {
var out bytes.Buffer
if err := out.WriteByte('['); err != nil {
return nil, err
}
for i, item := range a {
if _, err := out.WriteString(strconv.Quote(item)); err != nil {
return nil, err
}
// Skip the last element.
if i < len(a)-1 {
if err := out.WriteByte(','); err != nil {
return nil, err
}
}
}
err := out.WriteByte(']')
return out.Bytes(), err
}
type jsonStringVal string
func (str jsonStringVal) Value() (driver.Value, error) {
return strconv.Quote(string(str)), nil
}
type jsonKeyPath string
func (str jsonKeyPath) Value() (driver.Value, error) {
return "{" + string(str) + "}", nil
}
type JSONSerializable interface {
ToJSON() string
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestJSONArray(t *testing.T) {
input := []string{"a", "b"}
out, err := jsonArray(input).Value()
require.NoError(t, err)
outBuf, ok := out.([]byte)
require.True(t, ok)
assert.Equal(t, []byte(`["a","b"]`), outBuf)
}

View File

@@ -0,0 +1,380 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"encoding/json"
"fmt"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/einterfaces"
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
)
type SqlAttributesStore struct {
*SqlStore
metrics einterfaces.MetricsInterface
selectQueryBuilder sq.SelectBuilder
}
// attributeViewsByObjectType is the single source of truth for the
// object-type-to-materialized-view mapping: both the per-type lookup and the
// refresh sweep read it, so adding an object type cannot leave a new view
// perpetually stale.
var attributeViewsByObjectType = map[string]string{
model.PropertyFieldObjectTypeChannel: "ChannelAttributeView",
model.PropertyFieldObjectTypeUser: "UserAttributeView",
}
// attributeViewFor maps a property-field object type to its materialized view.
// An unrecognized type is an error rather than a fallback to the user view: a
// silent coercion would surface a future miswire as a wrong-but-successful user
// lookup.
func attributeViewFor(objectType string) (string, error) {
view, ok := attributeViewsByObjectType[objectType]
if !ok {
return "", errors.Errorf("unknown object type %q for attribute view", objectType)
}
return view, nil
}
func attributesSliceColumns(prefix ...string) []string {
var p string
if len(prefix) == 1 {
p = prefix[0] + "."
} else if len(prefix) > 1 {
panic("cannot accept multiple prefixes")
}
return []string{
p + "TargetID as ID",
p + "TargetType as Type",
p + "Attributes",
}
}
// qualify prefixes each column with the given table name. The members-to-remove
// queries join Users, whose columns (Roles, DeleteAt, CreateAt) collide with the
// member columns, so the member SELECT must be table-qualified to stay unambiguous.
func qualify(table string, columns []string) []string {
qualified := make([]string, len(columns))
for i, c := range columns {
qualified[i] = table + "." + c
}
return qualified
}
func newSqlAttributesStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterface) store.AttributesStore {
s := &SqlAttributesStore{
SqlStore: sqlStore,
metrics: metrics,
}
// The From clause is chosen per call by object type (GetSubject), so the
// shared builder carries only the Select.
s.selectQueryBuilder = s.getQueryBuilder().Select(attributesSliceColumns()...)
return s
}
func (s *SqlAttributesStore) RefreshAttributes() error {
// Both per-object-type views (migration 000216) refresh on one cadence, so a
// caller does not have to know which object types it is about to read. They
// refresh in separate statements, though, so this is not an atomic snapshot:
// a read straddling a refresh can see the two views a generation apart. Every
// consumer tolerates that — values are eventually consistent and the
// membership sync re-runs on cadence and converges. Refreshing only the view
// whose attributes actually changed is a scale follow-up. Refresh order is
// unspecified (map iteration) and does not matter: each REFRESH is its own
// independent statement.
for _, view := range attributeViewsByObjectType {
if _, err := s.GetMaster().Exec("REFRESH MATERIALIZED VIEW " + view); err != nil {
return errors.Wrapf(err, "error refreshing materialized view %s", view)
}
}
return nil
}
func (s *SqlAttributesStore) GetSubject(rctx request.CTX, ID, groupID, objectType string) (*model.Subject, error) {
view, err := attributeViewFor(objectType)
if err != nil {
return nil, err
}
query := s.selectQueryBuilder.From(view).Where(sq.And{sq.Eq{"TargetID": ID}, sq.Eq{"GroupID": groupID}})
q, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrap(err, "failed to build query for subject")
}
row := s.GetReplica().QueryRowContext(rctx.Context(), q, args...)
var subject model.Subject
var properties []byte
if err := row.Scan(&subject.ID, &subject.Type, &properties); err != nil {
if err == sql.ErrNoRows {
return nil, store.NewErrNotFound("Attributes", ID)
}
return nil, errors.Wrap(err, "failed to scan subject row")
}
if err := json.Unmarshal(properties, &subject.Attributes); err != nil {
return nil, errors.Wrap(err, "failed to unmarshal attributes")
}
return &subject, nil
}
func (s *SqlAttributesStore) SearchUsers(rctx request.CTX, opts model.SubjectSearchOptions) ([]*model.User, int64, error) {
query := s.getQueryBuilder().
Select(getUsersColumns()...).From("Users").LeftJoin("UserAttributeView ON Users.Id = UserAttributeView.TargetID").
OrderBy("Users.Id ASC")
count := s.getQueryBuilder().Select("COUNT(*)").From("Users").LeftJoin("UserAttributeView ON Users.Id = UserAttributeView.TargetID")
if opts.Query != "" {
// Wrap the CEL-derived expression in parentheses so that any top-level
// OR (e.g. produced by "has any of [a, b]") does not bind across the
// AND-joined WHERE clauses appended below (SubjectID, DeleteAt,
// TeamID, ExcludeChannelMembers, Cursor, Term). Without these
// parens, "A OR B AND Users.Id = $X" would be parsed as
// "A OR (B AND Users.Id = $X)" because AND binds tighter than OR.
wrapped := "(" + opts.Query + ")"
query = query.Where(sq.Expr(wrapped, opts.Args...))
count = count.Where(sq.Expr(wrapped, opts.Args...))
}
argCount := len(opts.Args)
if opts.Limit > 0 {
query = query.Limit(uint64(opts.Limit))
} else if opts.Limit > MaxPerPage {
query = query.Limit(uint64(MaxPerPage))
}
if !opts.AllowInactive {
query = query.Where("Users.DeleteAt = 0")
count = count.Where("Users.DeleteAt = 0")
}
if opts.TeamID != "" {
argCount++
query = query.Where(sq.Expr(fmt.Sprintf("Users.Id IN (SELECT UserId FROM TeamMembers WHERE TeamId = $%d AND DeleteAt = 0)", argCount), opts.TeamID))
count = count.Where(sq.Expr(fmt.Sprintf("Users.Id IN (SELECT UserId FROM TeamMembers WHERE TeamId = $%d AND DeleteAt = 0)", argCount), opts.TeamID))
}
if opts.ExcludeChannelMembers != "" {
argCount++
query = query.Where(sq.Expr(fmt.Sprintf("NOT EXISTS (SELECT 1 FROM ChannelMembers WHERE ChannelMembers.UserId = Users.Id AND ChannelMembers.ChannelId = $%d)", argCount), opts.ExcludeChannelMembers))
}
if opts.SubjectID != "" {
argCount++
query = query.Where(sq.Expr(fmt.Sprintf("Users.Id = $%d", argCount), opts.SubjectID))
count = count.Where(sq.Expr(fmt.Sprintf("Users.Id = $%d", argCount), opts.SubjectID))
}
if opts.Cursor.TargetID != "" {
argCount++
// Paginate on Users.Id (the ORDER BY column), not UserAttributeView.TargetID.
// The cursor value is a user id, and TargetID comes from a LEFT JOIN so it
// is NULL for users with no custom-attribute row — comparing against it
// silently drops those users (e.g. matches of a native-only policy).
query = query.Where(sq.Expr(fmt.Sprintf("Users.Id > $%d", argCount), opts.Cursor.TargetID))
}
termFields := UserSearchTypeNames
if opts.ExcludeFullNames {
termFields = UserSearchTypeNamesNoFullName
}
searchFields := make([]string, 0, len(termFields))
for _, field := range termFields {
searchFields = append(searchFields, strings.Join([]string{"Users", field}, "."))
}
if term := opts.Term; strings.TrimSpace(term) != "" {
_, query = generateSearchQueryForExpression(query, strings.Fields(term), searchFields, argCount)
_, count = generateSearchQueryForExpression(count, strings.Fields(term), searchFields, argCount)
}
q, args, err := query.ToSql()
if err != nil {
return nil, 0, errors.Wrap(err, "failed to build query for subjects")
}
users := []*model.User{}
if err = s.GetReplica().Select(&users, q, args...); err != nil {
return nil, 0, errors.Wrapf(err, "failed to find Users with term=%s and searchType=%v", opts.Term, searchFields)
}
for _, u := range users {
u.Sanitize(map[string]bool{})
}
var total int64
if !opts.IgnoreCount {
err = s.GetReplica().GetBuilder(&total, count)
if err != nil {
return nil, 0, errors.Wrapf(err, "failed to count Users with term=%s and searchType=%v", opts.Term, searchFields)
}
}
return users, total, nil
}
func (s *SqlAttributesStore) GetChannelMembersToRemove(rctx request.CTX, channelID string, opts model.SubjectSearchOptions) ([]*model.ChannelMember, error) {
query := s.getQueryBuilder().
Select(qualify("ChannelMembers", channelMemberSliceColumns())...).From("ChannelMembers").
// Join Users so native-attribute expressions (e.g. Users.EmailVerified)
// resolve here, mirroring SearchUsers on the add path.
LeftJoin("Users ON Users.Id = ChannelMembers.UserId").
LeftJoin("UserAttributeView ON ChannelMembers.UserId = UserAttributeView.TargetID").
OrderBy("ChannelMembers.UserId ASC")
if opts.Query != "" {
// A member is removed when they do NOT satisfy the policy; a NULL result
// (e.g. a missing custom attribute) counts as "does not satisfy" via
// COALESCE. We must not additionally remove members just because they
// lack a UserAttributeView row — a native-only policy matches against the
// Users table, so a user with zero custom attributes can still satisfy it.
query = query.Where(sq.Expr(fmt.Sprintf("NOT COALESCE((%s), FALSE)", opts.Query), opts.Args...))
}
argCount := len(opts.Args)
argCount++
query = query.Where(sq.Expr(fmt.Sprintf("ChannelMembers.ChannelId = $%d", argCount), channelID))
if opts.Limit > 0 {
query = query.Limit(uint64(opts.Limit))
} else if opts.Limit > MaxPerPage {
query = query.Limit(uint64(MaxPerPage))
}
if opts.Cursor.TargetID != "" {
argCount++
query = query.Where(sq.Expr(fmt.Sprintf("ChannelMembers.UserId > $%d", argCount), opts.Cursor.TargetID))
}
q, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrap(err, "failed to build query for subjects")
}
members := []*model.ChannelMember{}
if err := s.GetReplica().Select(&members, q, args...); err != nil {
return nil, errors.Wrapf(err, "failed to find channel members with for channel id=%s", channelID)
}
return members, nil
}
// Count folded in alongside the max to catch soft deletes: dropping a value that isn't the most
// recently updated leaves MAX(UpdateAt) unchanged, and the stale ETag would keep matching.
func (s *SqlAttributesStore) GetUserPropertyValuesEpoch(rctx request.CTX, userID string) (string, error) {
query, args, err := s.getQueryBuilder().
Select("COALESCE(MAX(UpdateAt), 0) AS MaxUpdateAt", "COUNT(*) AS Total").
From("PropertyValues").
Where(sq.Eq{"TargetID": userID}).
Where("DeleteAt = 0").
ToSql()
if err != nil {
return "", errors.Wrap(err, "GetUserPropertyValuesEpoch: failed to build query")
}
var epoch struct {
MaxUpdateAt int64
Total int64
}
// Master, not replica: the caller invalidates this key right after the write commits, so a
// lagging replica would re-cache the pre-write epoch and the client would keep 304ing onto
// content sanitized under the old attributes until the entry expires.
if err := s.GetMaster().Get(&epoch, query, args...); err != nil {
return "", errors.Wrap(err, "GetUserPropertyValuesEpoch: query failed")
}
return fmt.Sprintf("%d-%d", epoch.MaxUpdateAt, epoch.Total), nil
}
// No-op at the SQL layer; the per-user epoch is cached in and invalidated by the local cache layer.
func (s *SqlAttributesStore) InvalidateUserPropertyValuesEpoch(userID string) {}
func (s *SqlAttributesStore) ClearUserPropertyValuesEpochCache() {}
func (s *SqlAttributesStore) GetTeamMembersToRemove(rctx request.CTX, teamID string, opts model.SubjectSearchOptions) ([]*model.TeamMember, error) {
query := s.getQueryBuilder().
Select(qualify("TeamMembers", teamMemberSliceColumns())...).From("TeamMembers").
// Join Users so native-attribute expressions (e.g. Users.EmailVerified)
// resolve here, mirroring SearchUsers on the add path.
LeftJoin("Users ON Users.Id = TeamMembers.UserId").
LeftJoin("UserAttributeView ON TeamMembers.UserId = UserAttributeView.TargetID").
Where("TeamMembers.DeleteAt = 0").
OrderBy("TeamMembers.UserId ASC")
if opts.Query != "" {
// A member is removed when they do NOT satisfy the policy; a NULL result
// (e.g. a missing custom attribute) counts as "does not satisfy" via
// COALESCE. We must not additionally remove members just because they
// lack a UserAttributeView row — a native-only policy matches against the
// Users table, so a user with zero custom attributes can still satisfy it.
query = query.Where(sq.Expr(fmt.Sprintf("NOT COALESCE((%s), FALSE)", opts.Query), opts.Args...))
}
argCount := len(opts.Args)
argCount++
query = query.Where(sq.Expr(fmt.Sprintf("TeamMembers.TeamId = $%d", argCount), teamID))
// An explicit limit is capped at MaxPerPage; an unset limit (0) intentionally
// returns every removal candidate for the team. The membership-sync caller
// consumes the full set in one pass, so capping an unset limit here would
// permanently leave members beyond the cap in a team they no longer qualify
// for. The result is naturally bounded by the team's membership.
if opts.Limit > 0 {
limit := min(opts.Limit, MaxPerPage)
query = query.Limit(uint64(limit))
}
if opts.Cursor.TargetID != "" {
argCount++
query = query.Where(sq.Expr(fmt.Sprintf("TeamMembers.UserId > $%d", argCount), opts.Cursor.TargetID))
}
q, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrap(err, "failed to build query for subjects")
}
members := []*model.TeamMember{}
if err := s.GetReplica().Select(&members, q, args...); err != nil {
return nil, errors.Wrapf(err, "failed to find team members for team id=%s", teamID)
}
return members, nil
}
func generateSearchQueryForExpression(query sq.SelectBuilder, terms []string, fields []string, prevArgs int) (int, sq.SelectBuilder) {
for _, term := range terms {
searchFields := []string{}
termArgs := []any{}
for _, field := range fields {
prevArgs++
searchFields = append(searchFields, fmt.Sprintf("lower(%s) LIKE lower($%d) escape '*' ", field, prevArgs))
termArgs = append(termArgs, fmt.Sprintf("%%%s%%", strings.TrimLeft(term, "@")))
}
prevArgs++
searchFields = append(searchFields, fmt.Sprintf("lower(%s) LIKE lower($%d) escape '*' ", "Id", prevArgs))
termArgs = append(termArgs, strings.TrimLeft(term, "@"))
query = query.Where(fmt.Sprintf("(%s)", strings.Join(searchFields, " OR ")), termArgs...)
}
return prevArgs, query
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestAttributesStore(t *testing.T) {
StoreTestWithSqlStore(t, storetest.TestAttributesStore)
}

View File

@@ -0,0 +1,84 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
type SqlAuditStore struct {
*SqlStore
auditQuery sq.SelectBuilder
}
func newSqlAuditStore(sqlStore *SqlStore) store.AuditStore {
s := &SqlAuditStore{
SqlStore: sqlStore,
}
s.auditQuery = s.getQueryBuilder().
Select(
"Id",
"CreateAt",
"UserId",
"Action",
"ExtraInfo",
"IpAddress",
"SessionId",
).
From("Audits")
return s
}
func (s SqlAuditStore) Save(audit *model.Audit) error {
audit.Id = model.NewId()
audit.CreateAt = model.GetMillis()
if _, err := s.GetMaster().NamedExec(`INSERT INTO Audits
(Id, CreateAt, UserId, Action, ExtraInfo, IpAddress, SessionId)
VALUES
(:Id, :CreateAt, :UserId, :Action, :ExtraInfo, :IpAddress, :SessionId)`, audit); err != nil {
return errors.Wrapf(err, "failed to save Audit with userId=%s and action=%s", audit.UserId, audit.Action)
}
return nil
}
func (s SqlAuditStore) Get(userId string, offset int, limit int) (model.Audits, error) {
if limit > 1000 {
return nil, store.NewErrOutOfBounds(limit)
}
query := s.auditQuery.
OrderBy("CreateAt DESC").
Limit(uint64(limit)).
Offset(uint64(offset))
if userId != "" {
query = query.Where(sq.Eq{"UserId": userId})
}
queryString, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrap(err, "audits_tosql")
}
var audits model.Audits
if err := s.GetReplica().Select(&audits, queryString, args...); err != nil {
return nil, errors.Wrapf(err, "failed to get Audit list for userId=%s", userId)
}
return audits, nil
}
func (s SqlAuditStore) PermanentDeleteByUser(userId string) error {
if _, err := s.GetMaster().Exec("DELETE FROM Audits WHERE UserId = ?", userId); err != nil {
return errors.Wrapf(err, "failed to delete Audit with userId=%s", userId)
}
return nil
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestAuditStore(t *testing.T) {
StoreTest(t, storetest.TestAuditStore)
}

View File

@@ -0,0 +1,497 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"encoding/json"
"maps"
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
type SqlAutoTranslationStore struct {
*SqlStore
}
type TranslationMeta json.RawMessage
type Translation struct {
ObjectType string
ObjectID string
ChannelID *string
DstLang string
ProviderID string
NormHash string
Text string
Confidence *float64
Meta TranslationMeta
State string
UpdateAt int64
}
func (m *TranslationMeta) ToMap() (map[string]any, error) {
if m == nil {
return nil, nil
}
var result map[string]any
if err := json.Unmarshal(*m, &result); err != nil {
return nil, err
}
return result, nil
}
func newSqlAutoTranslationStore(sqlStore *SqlStore) store.AutoTranslationStore {
return &SqlAutoTranslationStore{
SqlStore: sqlStore,
}
}
func (s *SqlAutoTranslationStore) IsUserEnabled(userID, channelID string) (bool, error) {
query := s.getQueryBuilder().
Select("cm.AutoTranslationDisabled").
From("ChannelMembers cm").
Join("Channels c ON cm.ChannelId = c.Id").
Where(sq.Eq{"cm.UserId": userID, "cm.ChannelId": channelID}).
Where("cm.AutoTranslationDisabled != true").
Where("c.AutoTranslation = true")
var disabled bool
if err := s.GetReplica().GetBuilder(&disabled, query); err != nil {
if err == sql.ErrNoRows {
return false, nil
}
return false, errors.Wrapf(err, "failed to get user enabled status for user_id=%s, channel_id=%s", userID, channelID)
}
return !disabled, nil
}
func (s *SqlAutoTranslationStore) GetUserLanguage(userID, channelID string) (string, error) {
query := s.getQueryBuilder().
Select("u.Locale").
From("Users u").
Join("ChannelMembers cm ON u.Id = cm.UserId").
Join("Channels c ON cm.ChannelId = c.Id").
Where(sq.Eq{"u.Id": userID, "c.Id": channelID}).
Where("c.AutoTranslation = true").
Where("cm.AutoTranslationDisabled != true")
var locale string
if err := s.GetReplica().GetBuilder(&locale, query); err != nil {
if err == sql.ErrNoRows {
return "", nil
}
return "", errors.Wrapf(err, "failed to get user language for user_id=%s, channel_id=%s", userID, channelID)
}
return locale, nil
}
func (s *SqlAutoTranslationStore) GetActiveDestinationLanguages(channelID, excludeUserID string, filterUserIDs []string) ([]string, error) {
query := s.getQueryBuilder().
Select("DISTINCT u.Locale").
From("ChannelMembers cm").
Join("Channels c ON c.Id = cm.ChannelId").
Join("Users u ON u.Id = cm.UserId").
Where(sq.Eq{"cm.ChannelId": channelID}).
Where("c.AutoTranslation = true").
Where("cm.AutoTranslationDisabled != true")
// Filter to specific user IDs if provided (e.g., users with active WebSocket connections)
// When filterUserIDs is non-nil and non-empty, squirrel converts it to an IN clause
// Example: WHERE cm.userid IN ('id1', 'id2', 'id3')
if len(filterUserIDs) > 0 {
query = query.Where(sq.Eq{"cm.userid": filterUserIDs})
}
// Exclude specific user if provided (e.g., the message author)
// This works together with the filter above via SQL AND logic
// Example: WHERE cm.userid IN (...) AND cm.userid != 'excludedId'
if excludeUserID != "" {
query = query.Where(sq.NotEq{"cm.userid": excludeUserID})
}
var languages []string
if err := s.GetReplica().SelectBuilder(&languages, query); err != nil {
return nil, errors.Wrapf(err, "failed to get active destination languages for channel_id=%s", channelID)
}
return languages, nil
}
func (s *SqlAutoTranslationStore) Get(objectType, objectID, dstLang string) (*model.Translation, error) {
query := s.getQueryBuilder().
Select("ObjectType", "ObjectId", "DstLang", "ProviderId", "NormHash", "Text", "Confidence", "Meta", "State", "UpdateAt").
From("Translations").
Where(sq.Eq{"ObjectType": objectType, "ObjectId": objectID, "DstLang": dstLang})
var translation Translation
if err := s.GetReplica().GetBuilder(&translation, query); err != nil {
if err == sql.ErrNoRows {
return nil, store.NewErrNotFound("Translation", objectID)
}
return nil, errors.Wrapf(err, "failed to get translation for object_id=%s, dst_lang=%s", objectID, dstLang)
}
meta, err := translation.Meta.ToMap()
var translationTypeStr string
if err != nil {
return nil, errors.Wrapf(err, "failed to parse translation meta for object_id=%s", objectID)
}
if v, ok := meta["type"]; ok {
if s, ok := v.(string); ok {
translationTypeStr = s
}
}
result := &model.Translation{
ObjectID: translation.ObjectID,
ObjectType: objectType,
Lang: translation.DstLang,
Type: model.TranslationType(translationTypeStr),
Confidence: translation.Confidence,
State: model.TranslationState(translation.State),
NormHash: translation.NormHash,
Meta: meta,
}
if result.Type == model.TranslationTypeObject {
result.ObjectJSON = json.RawMessage(translation.Text)
} else {
result.Text = translation.Text
}
return result, nil
}
func (s *SqlAutoTranslationStore) GetBatch(objectType string, objectIDs []string, dstLang string) (map[string]*model.Translation, error) {
if len(objectIDs) == 0 {
return make(map[string]*model.Translation), nil
}
query := s.getQueryBuilder().
Select("ObjectType", "ObjectId", "DstLang", "ProviderId", "NormHash", "Text", "Confidence", "Meta", "State", "UpdateAt").
From("Translations").
Where(sq.Eq{"ObjectType": objectType, "ObjectId": objectIDs, "DstLang": dstLang})
var translations []Translation
if err := s.GetReplica().SelectBuilder(&translations, query); err != nil {
return nil, errors.Wrapf(err, "failed to get batch translations for dst_lang=%s", dstLang)
}
result := make(map[string]*model.Translation, len(translations))
for _, t := range translations {
var translationTypeStr string
meta, err := t.Meta.ToMap()
if err != nil {
// Log error but continue with other translations
continue
}
if v, ok := meta["type"]; ok {
if s, ok := v.(string); ok {
translationTypeStr = s
}
}
modelT := &model.Translation{
ObjectID: t.ObjectID,
ObjectType: objectType,
Lang: t.DstLang,
Type: model.TranslationType(translationTypeStr),
Confidence: t.Confidence,
State: model.TranslationState(t.State),
NormHash: t.NormHash,
Meta: meta,
UpdateAt: t.UpdateAt,
}
if modelT.Type == model.TranslationTypeObject {
modelT.ObjectJSON = json.RawMessage(t.Text)
} else {
modelT.Text = t.Text
}
result[t.ObjectID] = modelT
}
return result, nil
}
func (s *SqlAutoTranslationStore) GetAllForObject(objectType, objectID string) ([]*model.Translation, error) {
query := s.getQueryBuilder().
Select("ObjectType", "ObjectId", "DstLang", "ProviderId", "NormHash", "Text", "Confidence", "Meta", "State", "UpdateAt").
From("Translations").
Where(sq.Eq{"ObjectType": objectType, "ObjectId": objectID})
var translations []Translation
// Use GetMaster to avoid replica lag issues when workers fetch queued items
if err := s.GetMaster().SelectBuilder(&translations, query); err != nil {
return nil, errors.Wrapf(err, "failed to get all translations for object_id=%s", objectID)
}
result := make([]*model.Translation, 0, len(translations))
for _, t := range translations {
var translationTypeStr string
meta, err := t.Meta.ToMap()
if err != nil {
// Log error but continue with other translations
continue
}
if v, ok := meta["type"]; ok {
if s, ok := v.(string); ok {
translationTypeStr = s
}
}
modelT := &model.Translation{
ObjectID: t.ObjectID,
ObjectType: objectType,
Lang: t.DstLang,
Type: model.TranslationType(translationTypeStr),
Confidence: t.Confidence,
State: model.TranslationState(t.State),
NormHash: t.NormHash,
Meta: meta,
UpdateAt: t.UpdateAt,
}
if modelT.Type == model.TranslationTypeObject {
modelT.ObjectJSON = json.RawMessage(t.Text)
} else {
modelT.Text = t.Text
}
result = append(result, modelT)
}
return result, nil
}
func (s *SqlAutoTranslationStore) Save(translation *model.Translation) error {
if err := translation.IsValid(); err != nil {
return err
}
now := model.GetMillis()
var err error
text := translation.Text
if translation.Type == model.TranslationTypeObject && len(translation.ObjectJSON) > 0 {
text = string(translation.ObjectJSON)
}
objectType := translation.ObjectType
if objectType == "" {
objectType = model.TranslationObjectTypePost
}
var channelID *string
if translation.ChannelID != "" {
channelID = &translation.ChannelID
}
objectID := translation.ObjectID
// Preserve existing Meta fields and add/override "type"
metaMap := make(map[string]any)
if translation.Meta != nil {
// Copy existing Meta fields (e.g., "src_lang", "error", etc.)
maps.Copy(metaMap, translation.Meta)
}
// Always set "type" field
metaMap["type"] = string(translation.Type)
metaBytes, err := json.Marshal(metaMap)
if err != nil {
return errors.Wrap(err, "failed to marshal translation meta")
}
// Apply binary flag if enabled (required for PostgreSQL JSONB with binary_parameters=yes)
if s.IsBinaryParamEnabled() {
metaBytes = AppendBinaryFlag(metaBytes)
}
dstLang := translation.Lang
providerID := translation.Provider
confidence := translation.Confidence
query := s.getQueryBuilder().
Insert("Translations").
Columns("ObjectId", "DstLang", "ObjectType", "ChannelId", "ProviderId", "NormHash", "Text", "Confidence", "Meta", "State", "UpdateAt").
Values(objectID, dstLang, objectType, channelID, providerID, translation.NormHash, text, confidence, metaBytes, string(translation.State), now).
Suffix(`ON CONFLICT (ObjectId, ObjectType, dstLang)
DO UPDATE SET
ChannelId = EXCLUDED.ChannelId,
ProviderId = EXCLUDED.ProviderId,
NormHash = EXCLUDED.NormHash,
Text = EXCLUDED.Text,
Confidence = EXCLUDED.Confidence,
Meta = EXCLUDED.Meta,
State = EXCLUDED.State,
UpdateAt = EXCLUDED.UpdateAt
WHERE Translations.NormHash IS DISTINCT FROM EXCLUDED.NormHash
OR Translations.State != EXCLUDED.State`)
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
return errors.Wrapf(err, "failed to save translation for object_id=%s, lang=%s", objectID, dstLang)
}
return nil
}
func (s *SqlAutoTranslationStore) GetByStateOlderThan(state model.TranslationState, olderThanMillis int64, limit int) ([]*model.Translation, error) {
query := s.getQueryBuilder().
Select("ObjectType", "ObjectId", "DstLang", "ProviderId", "NormHash", "Text", "Confidence", "Meta", "State", "UpdateAt").
From("Translations").
Where(sq.Eq{"State": string(state)}).
Where(sq.Lt{"UpdateAt": olderThanMillis}).
OrderBy("UpdateAt ASC").
Limit(uint64(limit))
var translations []Translation
if err := s.GetReplica().SelectBuilder(&translations, query); err != nil {
return nil, errors.Wrapf(err, "failed to get translations by state=%s older than %d", state, olderThanMillis)
}
result := make([]*model.Translation, 0, len(translations))
for _, t := range translations {
var translationTypeStr string
meta, err := t.Meta.ToMap()
if err != nil {
// Log error but continue with other translations
continue
}
if v, ok := meta["type"]; ok {
if s, ok := v.(string); ok {
translationTypeStr = s
}
}
// Default objectType to "post" if not set
objectType := t.ObjectType
if objectType == "" {
objectType = model.TranslationObjectTypePost
}
modelT := &model.Translation{
ObjectID: t.ObjectID,
ObjectType: objectType,
Lang: t.DstLang,
Type: model.TranslationType(translationTypeStr),
Confidence: t.Confidence,
State: model.TranslationState(t.State),
NormHash: t.NormHash,
Meta: meta,
UpdateAt: t.UpdateAt,
}
if modelT.Type == model.TranslationTypeObject {
modelT.ObjectJSON = json.RawMessage(t.Text)
} else {
modelT.Text = t.Text
}
result = append(result, modelT)
}
return result, nil
}
func (s *SqlAutoTranslationStore) ClearCaches() {}
func (s *SqlAutoTranslationStore) InvalidateUserAutoTranslation(userID, channelID string) {}
func (s *SqlAutoTranslationStore) InvalidateUserLocaleCache(userID string) {}
// GetLatestPostUpdateAtForChannel returns the most recent updateAt timestamp for post translations
// in the given channel (across all locales). Uses a direct lookup on the channelid column
// for O(1) performance. Returns 0 if no translations exist.
func (s *SqlAutoTranslationStore) GetLatestPostUpdateAtForChannel(channelID string) (int64, error) {
query := s.getQueryBuilder().
Select("COALESCE(MAX(updateAt), 0)").
From("translations").
Where(sq.Eq{"channelid": channelID}).
Where(sq.Eq{"objectType": model.TranslationObjectTypePost})
var updateAt int64
if err := s.GetReplica().GetBuilder(&updateAt, query); err != nil {
return 0, errors.Wrapf(err, "failed to get latest translation updateAt for channel_id=%s", channelID)
}
return updateAt, nil
}
func (s *SqlAutoTranslationStore) InvalidatePostTranslationEtag(channelID string) {}
func (s *SqlAutoTranslationStore) GetTranslationsSinceForChannel(channelID, dstLang string, since int64) (map[string]*model.Translation, error) {
query := s.getQueryBuilder().
Select("ObjectType", "ObjectId", "DstLang", "ProviderId", "NormHash", "Text", "Confidence", "Meta", "State", "UpdateAt").
From("Translations").
Where(sq.Eq{"channelid": channelID}).
Where(sq.Eq{"DstLang": dstLang}).
Where(sq.Eq{"ObjectType": model.TranslationObjectTypePost}).
Where(sq.NotEq{"State": string(model.TranslationStateProcessing)}).
Where(sq.Gt{"UpdateAt": since}).
Limit(1000)
var translations []Translation
if err := s.GetReplica().SelectBuilder(&translations, query); err != nil {
return nil, errors.Wrapf(err, "failed to get translations since for channel_id=%s dst_lang=%s", channelID, dstLang)
}
result := make(map[string]*model.Translation, len(translations))
for _, t := range translations {
var translationTypeStr string
meta, err := t.Meta.ToMap()
if err != nil {
continue
}
if v, ok := meta["type"]; ok {
if s, ok := v.(string); ok {
translationTypeStr = s
}
}
objectType := t.ObjectType
if objectType == "" {
objectType = model.TranslationObjectTypePost
}
modelT := &model.Translation{
ObjectID: t.ObjectID,
ObjectType: objectType,
Lang: t.DstLang,
Type: model.TranslationType(translationTypeStr),
Confidence: t.Confidence,
State: model.TranslationState(t.State),
NormHash: t.NormHash,
Meta: meta,
UpdateAt: t.UpdateAt,
}
if modelT.Type == model.TranslationTypeObject {
modelT.ObjectJSON = json.RawMessage(t.Text)
} else {
modelT.Text = t.Text
}
result[t.ObjectID] = modelT
}
return result, nil
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestAutoTranslationStore(t *testing.T) {
StoreTestWithSqlStore(t, storetest.TestAutoTranslationStore)
}

View File

@@ -0,0 +1,270 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"fmt"
"strings"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/einterfaces"
sq "github.com/mattermost/squirrel"
)
// bot is a subset of the model.Bot type, omitting the model.User fields.
type bot struct {
UserId string `json:"user_id"`
Description string `json:"description"`
OwnerId string `json:"owner_id"`
LastIconUpdate int64 `json:"last_icon_update"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
DeleteAt int64 `json:"delete_at"`
}
func botFromModel(b *model.Bot) *bot {
return &bot{
UserId: b.UserId,
Description: b.Description,
OwnerId: b.OwnerId,
LastIconUpdate: b.LastIconUpdate,
CreateAt: b.CreateAt,
UpdateAt: b.UpdateAt,
DeleteAt: b.DeleteAt,
}
}
// SqlBotStore is a store for managing bots in the database.
// Bots are otherwise normal users with extra metadata record in the Bots table. The primary key
// for a bot matches the primary key value for corresponding User record.
type SqlBotStore struct {
*SqlStore
metrics einterfaces.MetricsInterface
// botsQuery is a starting point for all queries that return one or more Bots.
botsQuery sq.SelectBuilder
}
// newSqlBotStore creates an instance of SqlBotStore, registering the table schema in question.
func newSqlBotStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterface) store.BotStore {
bs := &SqlBotStore{
SqlStore: sqlStore,
metrics: metrics,
}
// note: we are providing field names explicitly here to maintain order of columns (needed when using raw queries)
bs.botsQuery = bs.getQueryBuilder().
Select("b.UserId", "u.Username", "u.FirstName AS DisplayName", "b.Description", "b.OwnerId", "COALESCE(b.LastIconUpdate, 0) AS LastIconUpdate", "b.CreateAt", "b.UpdateAt", "b.DeleteAt").
From("Bots b").
Join("Users u ON ( u.Id = b.UserId )")
return bs
}
// Get fetches the given bot in the database.
func (us SqlBotStore) Get(botUserId string, includeDeleted bool) (*model.Bot, error) {
var excludeDeletedSql = "AND b.DeleteAt = 0"
if includeDeleted {
excludeDeletedSql = ""
}
query := `
SELECT
b.UserId,
u.Username,
u.FirstName AS DisplayName,
b.Description,
b.OwnerId,
COALESCE(b.LastIconUpdate, 0) AS LastIconUpdate,
b.CreateAt,
b.UpdateAt,
b.DeleteAt
FROM
Bots b
JOIN
Users u ON (u.Id = b.UserId)
WHERE
b.UserId = ?
` + excludeDeletedSql + `
`
var bot model.Bot
if err := us.GetReplica().Get(&bot, query, botUserId); err == sql.ErrNoRows {
return nil, store.NewErrNotFound("Bot", botUserId)
} else if err != nil {
return nil, errors.Wrapf(err, "selectone: user_id=%s", botUserId)
}
return &bot, nil
}
// GetAll fetches from all bots in the database.
func (us SqlBotStore) GetAll(options *model.BotGetOptions) ([]*model.Bot, error) {
var conditions []string
var conditionsSql string
var additionalJoin string
var args []any
if !options.IncludeDeleted {
conditions = append(conditions, "b.DeleteAt = 0")
}
if options.OwnerId != "" {
conditions = append(conditions, "b.OwnerId = ?")
args = append(args, options.OwnerId)
}
if options.OnlyOrphaned {
additionalJoin = "JOIN Users o ON (o.Id = b.OwnerId)"
conditions = append(conditions, "o.DeleteAt != 0")
}
if len(conditions) > 0 {
conditionsSql = "WHERE " + strings.Join(conditions, " AND ")
}
sql := `
SELECT
b.UserId,
u.Username,
u.FirstName AS DisplayName,
b.Description,
b.OwnerId,
COALESCE(b.LastIconUpdate, 0) AS LastIconUpdate,
b.CreateAt,
b.UpdateAt,
b.DeleteAt
FROM
Bots b
JOIN
Users u ON (u.Id = b.UserId)
` + additionalJoin + `
` + conditionsSql + `
ORDER BY
b.CreateAt ASC,
u.Username ASC
LIMIT
?
OFFSET
?
`
// append limit, offset
args = append(args, options.PerPage, options.Page*options.PerPage)
bots := []*model.Bot{}
if err := us.GetReplica().Select(&bots, sql, args...); err != nil {
return nil, errors.Wrap(err, "error selecting all bots")
}
return bots, nil
}
// Save persists a new bot to the database.
// It assumes the corresponding user was saved via the user store.
func (us SqlBotStore) Save(bot *model.Bot) (*model.Bot, error) {
bot = bot.Clone()
bot.PreSave()
if err := bot.IsValid(); err != nil { // TODO: change to return error in v6.
return nil, err
}
if _, err := us.GetMaster().NamedExec(`INSERT INTO Bots
(UserId, Description, OwnerId, LastIconUpdate, CreateAt, UpdateAt, DeleteAt)
VALUES
(:UserId, :Description, :OwnerId, :LastIconUpdate, :CreateAt, :UpdateAt, :DeleteAt)`, botFromModel(bot)); err != nil {
return nil, errors.Wrapf(err, "insert: user_id=%s", bot.UserId)
}
return bot, nil
}
// Update persists an updated bot to the database.
// It assumes the corresponding user was updated via the user store.
func (us SqlBotStore) Update(bot *model.Bot) (*model.Bot, error) {
bot = bot.Clone()
bot.PreUpdate()
if err := bot.IsValid(); err != nil { // TODO: needs to return error in v6
return nil, err
}
oldBot, err := us.Get(bot.UserId, true)
if err != nil {
return nil, err
}
oldBot.Description = bot.Description
oldBot.OwnerId = bot.OwnerId
oldBot.LastIconUpdate = bot.LastIconUpdate
oldBot.UpdateAt = bot.UpdateAt
oldBot.DeleteAt = bot.DeleteAt
bot = oldBot
res, err := us.GetMaster().NamedExec(`UPDATE Bots
SET Description=:Description, OwnerId=:OwnerId, LastIconUpdate=:LastIconUpdate,
UpdateAt=:UpdateAt, DeleteAt=:DeleteAt
WHERE UserId=:UserId`, botFromModel(bot))
if err != nil {
return nil, errors.Wrapf(err, "update: user_id=%s", bot.UserId)
}
count, err := res.RowsAffected()
if err != nil {
return nil, errors.Wrap(err, "error while getting rows_affected")
}
if count > 1 {
return nil, fmt.Errorf("unexpected count while updating bot: count=%d, userId=%s", count, bot.UserId)
}
return bot, nil
}
// PermanentDelete removes the bot from the database altogether.
// If the corresponding user is to be deleted, it must be done via the user store.
func (us SqlBotStore) PermanentDelete(botUserId string) error {
query := "DELETE FROM Bots WHERE UserId = ?"
if _, err := us.GetMaster().Exec(query, botUserId); err != nil {
return store.NewErrInvalidInput("Bot", "UserId", botUserId).Wrap(err)
}
return nil
}
func (us SqlBotStore) GetAllAfter(limit int, afterId string) ([]*model.Bot, error) {
query := us.botsQuery.Where("b.UserId > ?", afterId).OrderBy("b.UserId ASC").Limit(uint64(limit))
queryString, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrap(err, "get_all_after_tosql")
}
bots := []*model.Bot{}
if err := us.GetReplica().Select(&bots, queryString, args...); err != nil {
return nil, errors.Wrap(err, "failed to find Bots")
}
return bots, nil
}
// Get fetches the given bot in the database.
func (us SqlBotStore) GetByUsername(username string) (*model.Bot, error) {
query := us.botsQuery.Where("u.Username = lower(?)", username)
queryString, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrap(err, "get_by_username_tosql")
}
bot := model.Bot{}
if err := us.GetReplica().Get(&bot, queryString, args...); err != nil {
if err == sql.ErrNoRows {
return nil, errors.Wrap(store.NewErrNotFound("Bot", fmt.Sprintf("username=%s", username)), "failed to find Bot")
}
return nil, errors.Wrapf(err, "failed to find Bot with username=%s", username)
}
return &bot, nil
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestBotStore(t *testing.T) {
StoreTestWithSqlStore(t, storetest.TestBotStore)
}

View File

@@ -0,0 +1,391 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestBulkInsertChunking(t *testing.T) {
StoreTest(t, testBulkInsertChunking)
}
// setMaxInsertParams overrides the max-insert-params threshold on the
// underlying SqlStore and returns a cleanup function that restores the default.
func setMaxInsertParams(t *testing.T, ss store.Store, maxParams int) {
t.Helper()
sqlStore := ss.(*SqlStore)
sqlStore.maxInsertParams = maxParams
t.Cleanup(func() { sqlStore.maxInsertParams = 0 })
}
func testBulkInsertChunking(t *testing.T, rctx request.CTX, ss store.Store) {
t.Run("ChannelMembers across multiple chunks", func(t *testing.T) {
// 15 columns/row. With maxInsertParams=30, chunkSize=2,
// so 5 members split into 3 chunks (2+2+1).
setMaxInsertParams(t, ss, 30)
team := &model.Team{
DisplayName: "ChunkTest",
Name: storetest.NewTestID(),
Email: storetest.MakeEmail(),
Type: model.TeamOpen,
}
team, err := ss.Team().Save(team)
require.NoError(t, err)
channel := &model.Channel{
DisplayName: "ChunkTest",
Name: "z-z-z" + model.NewId(),
Type: model.ChannelTypeOpen,
TeamId: team.Id,
}
channel, err = ss.Channel().Save(rctx, channel, -1)
require.NoError(t, err)
const n = 5
members := make([]*model.ChannelMember, n)
defaultNotifyProps := model.GetDefaultChannelNotifyProps()
for i := range members {
u, uErr := ss.User().Save(rctx, &model.User{Username: model.NewUsername(), Email: storetest.MakeEmail()})
require.NoError(t, uErr)
members[i] = &model.ChannelMember{
ChannelId: channel.Id,
UserId: u.Id,
NotifyProps: defaultNotifyProps,
}
}
saved, err := ss.Channel().SaveMultipleMembers(members)
require.NoError(t, err)
require.Len(t, saved, n)
for _, m := range saved {
got, gErr := ss.Channel().GetMember(rctx, channel.Id, m.UserId)
require.NoError(t, gErr)
require.Equal(t, channel.Id, got.ChannelId)
}
})
t.Run("ChannelMembers atomicity on conflict", func(t *testing.T) {
// With chunkSize=2, inserting [A, B, B] produces chunks [A,B] and [B].
// Chunk 1 succeeds; chunk 2 hits a unique constraint. The transaction
// must roll back all chunks, leaving zero rows.
setMaxInsertParams(t, ss, 30)
team := &model.Team{
DisplayName: "AtomicTest",
Name: storetest.NewTestID(),
Email: storetest.MakeEmail(),
Type: model.TeamOpen,
}
team, err := ss.Team().Save(team)
require.NoError(t, err)
channel := &model.Channel{
DisplayName: "AtomicTest",
Name: "z-z-z" + model.NewId(),
Type: model.ChannelTypeOpen,
TeamId: team.Id,
}
channel, err = ss.Channel().Save(rctx, channel, -1)
require.NoError(t, err)
users := make([]*model.User, 3)
for i := range users {
u, uErr := ss.User().Save(rctx, &model.User{Username: model.NewUsername(), Email: storetest.MakeEmail()})
require.NoError(t, uErr)
users[i] = u
}
defaultNotifyProps := model.GetDefaultChannelNotifyProps()
members := []*model.ChannelMember{
{ChannelId: channel.Id, UserId: users[0].Id, NotifyProps: defaultNotifyProps},
{ChannelId: channel.Id, UserId: users[1].Id, NotifyProps: defaultNotifyProps},
{ChannelId: channel.Id, UserId: users[1].Id, NotifyProps: defaultNotifyProps}, // duplicate
}
_, err = ss.Channel().SaveMultipleMembers(members)
require.Error(t, err)
// Verify none were persisted (transaction rolled back).
_, gErr := ss.Channel().GetMember(rctx, channel.Id, users[0].Id)
require.Error(t, gErr, "member from chunk 1 should not exist after rollback")
})
t.Run("TeamMembers across multiple chunks", func(t *testing.T) {
// 8 columns/row. With maxInsertParams=16, chunkSize=2,
// so 5 members split into 3 chunks (2+2+1).
setMaxInsertParams(t, ss, 16)
teamID := model.NewId()
const n = 5
members := make([]*model.TeamMember, n)
for i := range members {
u, uErr := ss.User().Save(rctx, &model.User{Username: model.NewUsername(), Email: storetest.MakeEmail()})
require.NoError(t, uErr)
members[i] = &model.TeamMember{TeamId: teamID, UserId: u.Id}
}
saved, err := ss.Team().SaveMultipleMembers(members, -1)
require.NoError(t, err)
require.Len(t, saved, n)
for _, m := range saved {
got, gErr := ss.Team().GetMember(rctx, teamID, m.UserId)
require.NoError(t, gErr)
require.Equal(t, teamID, got.TeamId)
}
})
t.Run("TeamMembers atomicity on conflict", func(t *testing.T) {
setMaxInsertParams(t, ss, 16)
teamID := model.NewId()
users := make([]*model.User, 3)
for i := range users {
u, uErr := ss.User().Save(rctx, &model.User{Username: model.NewUsername(), Email: storetest.MakeEmail()})
require.NoError(t, uErr)
users[i] = u
}
members := []*model.TeamMember{
{TeamId: teamID, UserId: users[0].Id},
{TeamId: teamID, UserId: users[1].Id},
{TeamId: teamID, UserId: users[1].Id}, // duplicate
}
_, err := ss.Team().SaveMultipleMembers(members, -1)
require.Error(t, err)
_, gErr := ss.Team().GetMember(rctx, teamID, users[0].Id)
require.Error(t, gErr, "member from chunk 1 should not exist after rollback")
})
t.Run("ThreadMemberships across multiple chunks", func(t *testing.T) {
// 6 columns/row. With maxInsertParams=12, chunkSize=2,
// so 5 memberships split into 3 chunks (2+2+1).
setMaxInsertParams(t, ss, 12)
const n = 5
memberships := make([]*model.ThreadMembership, n)
for i := range memberships {
memberships[i] = &model.ThreadMembership{
PostId: model.NewId(),
UserId: model.NewId(),
Following: true,
}
}
saved, err := ss.Thread().SaveMultipleMemberships(memberships)
require.NoError(t, err)
require.Len(t, saved, n)
for _, m := range saved {
got, gErr := ss.Thread().GetMembershipForUser(m.UserId, m.PostId)
require.NoError(t, gErr)
require.Equal(t, m.PostId, got.PostId)
require.Equal(t, m.UserId, got.UserId)
}
})
t.Run("Posts across multiple chunks", func(t *testing.T) {
// 18 columns/row. With maxInsertParams=36, chunkSize=2,
// so 5 posts split into 3 chunks (2+2+1).
setMaxInsertParams(t, ss, 36)
channel := &model.Channel{
DisplayName: "PostChunkTest",
Name: "z-z-z" + model.NewId(),
Type: model.ChannelTypeOpen,
TeamId: model.NewId(),
}
channel, err := ss.Channel().Save(rctx, channel, -1)
require.NoError(t, err)
u, err := ss.User().Save(rctx, &model.User{Username: model.NewUsername(), Email: storetest.MakeEmail()})
require.NoError(t, err)
const n = 5
posts := make([]*model.Post, n)
for i := range posts {
posts[i] = &model.Post{
ChannelId: channel.Id,
UserId: u.Id,
Message: "chunk test post",
}
}
saved, _, err := ss.Post().SaveMultiple(rctx, posts)
require.NoError(t, err)
require.Len(t, saved, n)
for _, p := range saved {
got, gErr := ss.Post().GetSingle(rctx, p.Id, false)
require.NoError(t, gErr)
require.Equal(t, p.Id, got.Id)
}
})
t.Run("Posts atomicity on conflict", func(t *testing.T) {
// Use remote posts to allow pre-set IDs. With chunkSize=2,
// posts [A, B, A-dup] produce chunks [A,B] and [A-dup].
// Chunk 2 hits PK violation; transaction must roll back all.
setMaxInsertParams(t, ss, 36)
channel := &model.Channel{
DisplayName: "PostAtomicTest",
Name: "z-z-z" + model.NewId(),
Type: model.ChannelTypeOpen,
TeamId: model.NewId(),
}
channel, err := ss.Channel().Save(rctx, channel, -1)
require.NoError(t, err)
u, err := ss.User().Save(rctx, &model.User{Username: model.NewUsername(), Email: storetest.MakeEmail()})
require.NoError(t, err)
remoteId := new(model.NewId())
duplicateId := model.NewId()
posts := []*model.Post{
{Id: duplicateId, RemoteId: remoteId, ChannelId: channel.Id, UserId: u.Id, Message: "post A"},
{Id: model.NewId(), RemoteId: remoteId, ChannelId: channel.Id, UserId: u.Id, Message: "post B"},
{Id: duplicateId, RemoteId: remoteId, ChannelId: channel.Id, UserId: u.Id, Message: "post A dup"},
}
_, _, err = ss.Post().SaveMultiple(rctx, posts)
require.Error(t, err)
// Post from chunk 1 should not exist (transaction rolled back).
_, gErr := ss.Post().GetSingle(rctx, duplicateId, false)
require.Error(t, gErr, "post from chunk 1 should not exist after rollback")
})
t.Run("GroupMembers via CreateWithUserIds atomicity on conflict", func(t *testing.T) {
// With chunkSize=2, userIds [A, B, B] produce chunks [A,B] and [B].
// Chunk 2 hits unique constraint; transaction must roll back all
// (including the group row itself).
setMaxInsertParams(t, ss, 8)
users := make([]*model.User, 2)
for i := range users {
u, uErr := ss.User().Save(rctx, &model.User{Username: model.NewUsername(), Email: storetest.MakeEmail()})
require.NoError(t, uErr)
users[i] = u
}
group := &model.GroupWithUserIds{
Group: model.Group{
DisplayName: "AtomicGroup",
Name: new("atomic-group-" + model.NewId()),
Source: model.GroupSourceCustom,
},
UserIds: []string{users[0].Id, users[1].Id, users[1].Id}, // duplicate
}
_, err := ss.Group().CreateWithUserIds(group)
require.Error(t, err)
})
t.Run("Status upserts across multiple chunks", func(t *testing.T) {
// 6 columns/row. With maxInsertParams=12, chunkSize=2,
// so 5 statuses split into 3 chunks (2+2+1).
setMaxInsertParams(t, ss, 12)
const n = 5
statuses := make(map[string]*model.Status, n)
userIDs := make([]string, 0, n)
for range n {
u, uErr := ss.User().Save(rctx, &model.User{Username: model.NewUsername(), Email: storetest.MakeEmail()})
require.NoError(t, uErr)
statuses[u.Id] = &model.Status{
UserId: u.Id,
Status: model.StatusOnline,
Manual: false,
}
userIDs = append(userIDs, u.Id)
}
err := ss.Status().SaveOrUpdateMany(statuses)
require.NoError(t, err)
for _, uid := range userIDs {
got, gErr := ss.Status().Get(uid)
require.NoError(t, gErr)
require.Equal(t, model.StatusOnline, got.Status)
}
})
t.Run("GroupMembers via CreateWithUserIds across chunks", func(t *testing.T) {
// 4 columns/row. With maxInsertParams=8, chunkSize=2,
// so 5 members split into 3 chunks (2+2+1).
setMaxInsertParams(t, ss, 8)
const n = 5
userIDs := make([]string, n)
for i := range userIDs {
u, uErr := ss.User().Save(rctx, &model.User{Username: model.NewUsername(), Email: storetest.MakeEmail()})
require.NoError(t, uErr)
userIDs[i] = u.Id
}
group := &model.GroupWithUserIds{
Group: model.Group{
DisplayName: "ChunkGroup",
Name: new("chunk-group-" + model.NewId()),
Source: model.GroupSourceCustom,
},
UserIds: userIDs,
}
created, err := ss.Group().CreateWithUserIds(group)
require.NoError(t, err)
require.NotNil(t, created)
members, err := ss.Group().GetMemberUsers(created.Id)
require.NoError(t, err)
require.Len(t, members, n)
})
t.Run("GroupMembers via UpsertMembers across chunks", func(t *testing.T) {
// 4 columns/row. With maxInsertParams=8, chunkSize=2,
// so 5 members split into 3 chunks (2+2+1).
setMaxInsertParams(t, ss, 8)
// Create a group first (with no members).
group := &model.GroupWithUserIds{
Group: model.Group{
DisplayName: "UpsertGroup",
Name: new("upsert-group-" + model.NewId()),
Source: model.GroupSourceCustom,
},
}
created, err := ss.Group().CreateWithUserIds(group)
require.NoError(t, err)
const n = 5
userIDs := make([]string, n)
for i := range userIDs {
u, uErr := ss.User().Save(rctx, &model.User{Username: model.NewUsername(), Email: storetest.MakeEmail()})
require.NoError(t, uErr)
userIDs[i] = u.Id
}
upserted, err := ss.Group().UpsertMembers(created.Id, userIDs)
require.NoError(t, err)
require.Len(t, upserted, n)
members, err := ss.Group().GetMemberUsers(created.Id)
require.NoError(t, err)
require.Len(t, members, n)
})
}

View File

@@ -0,0 +1,394 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"slices"
"strconv"
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/channels/utils"
)
type SqlChannelBookmarkStore struct {
*SqlStore
}
func newSqlChannelBookmarkStore(sqlStore *SqlStore) store.ChannelBookmarkStore {
return &SqlChannelBookmarkStore{sqlStore}
}
func bookmarkWithFileInfoSliceColumns() []string {
return []string{
"cb.Id",
"cb.OwnerId",
"cb.ChannelId",
"cb.FileInfoId",
"cb.CreateAt",
"cb.UpdateAt",
"cb.DeleteAt",
"cb.DisplayName",
"cb.SortOrder",
"cb.LinkUrl",
"cb.ImageUrl",
"cb.Emoji",
"cb.Type",
"COALESCE(cb.TargetId, '') as TargetId",
"COALESCE(cb.OriginalId, '') as OriginalId",
"COALESCE(fi.Id, '') as FileId",
"COALESCE(fi.Name, '') as FileName",
"COALESCE(fi.Extension, '') as Extension",
"COALESCE(fi.Size, 0) as Size",
"COALESCE(fi.MimeType, '') as MimeType",
"COALESCE(fi.Width, 0) as Width",
"COALESCE(fi.Height, 0) as Height",
"COALESCE(fi.HasPreviewImage, false) as HasPreviewImage",
"COALESCE(fi.MiniPreview, '') as MiniPreview",
}
}
func (s *SqlChannelBookmarkStore) ErrorIfBookmarkFileInfoAlreadyAttached(fileId string, channelId string) error {
existingQuery := s.getSubQueryBuilder().
Select("FileInfoId").
From("ChannelBookmarks").
Where(sq.And{
sq.Eq{"FileInfoId": fileId},
sq.Eq{"DeleteAt": 0},
})
alreadyAttachedQuery := s.getQueryBuilder().
Select("COUNT(*)").
From("FileInfo").
Where(sq.Or{
sq.Expr("Id IN (?)", existingQuery),
sq.And{
sq.Eq{"Id": fileId},
sq.Or{
sq.NotEq{"PostId": ""},
sq.NotEq{"CreatorId": model.BookmarkFileOwner},
sq.NotEq{"ChannelId": channelId},
sq.NotEq{"DeleteAt": 0},
},
},
})
var attached int64
err := s.GetReplica().GetBuilder(&attached, alreadyAttachedQuery)
if err != nil {
return errors.Wrap(err, "unable_to_save_channel_bookmark")
}
if attached > 0 {
return store.NewErrInvalidInput("ChannelBookmarks", "FileInfoId", fileId)
}
return nil
}
func (s *SqlChannelBookmarkStore) Get(Id string, includeDeleted bool) (*model.ChannelBookmarkWithFileInfo, error) {
query := s.getQueryBuilder().
Select(bookmarkWithFileInfoSliceColumns()...).
From("ChannelBookmarks cb").
LeftJoin("FileInfo fi ON cb.FileInfoId = fi.Id").
Where(sq.Eq{"cb.Id": Id})
if !includeDeleted {
query = query.Where(sq.Eq{"cb.DeleteAt": 0})
}
queryString, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrap(err, "channel_bookmark_getforchanneltsince_tosql")
}
bookmark := model.ChannelBookmarkAndFileInfo{}
if err := s.GetReplica().Get(&bookmark, queryString, args...); err != nil {
return nil, store.NewErrNotFound("ChannelBookmark", Id)
}
return bookmark.ToChannelBookmarkWithFileInfo(), nil
}
func (s *SqlChannelBookmarkStore) Save(bookmark *model.ChannelBookmark, increaseSortOrder bool) (b *model.ChannelBookmarkWithFileInfo, err error) {
bookmark.PreSave()
if err := bookmark.IsValid(); err != nil {
return nil, err
}
transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, err
}
defer finalizeTransactionX(transaction, &err)
var currentBookmarksCount int64
query := s.getQueryBuilder().
Select("COUNT(*) as count").
From("ChannelBookmarks").
Where(sq.Eq{"ChannelId": bookmark.ChannelId, "DeleteAt": 0})
err = transaction.GetBuilder(&currentBookmarksCount, query)
if err != nil {
return nil, errors.Wrap(err, "failed while getting the count of ChannelBookmarks")
}
if currentBookmarksCount >= model.MaxBookmarksPerChannel {
return nil, store.NewErrLimitExceeded("bookmarks_per_channel", int(currentBookmarksCount), "channelId="+bookmark.ChannelId)
}
if bookmark.FileId != "" {
err = s.ErrorIfBookmarkFileInfoAlreadyAttached(bookmark.FileId, bookmark.ChannelId)
if err != nil {
return nil, errors.Wrap(err, "unable_to_save_channel_bookmark")
}
}
if increaseSortOrder {
var sortOrder int64
query := s.getQueryBuilder().
Select("COALESCE(MAX(SortOrder), -1) as SortOrder").
From("ChannelBookmarks").
Where(sq.Eq{"ChannelId": bookmark.ChannelId, "DeleteAt": 0})
err = transaction.GetBuilder(&sortOrder, query)
if err != nil {
return nil, errors.Wrap(err, "failed while getting the sortOrder from ChannelBookmarks")
}
bookmark.SortOrder = sortOrder + 1
}
targetID := any(bookmark.TargetId)
if bookmark.TargetId == "" {
targetID = nil
}
sql, args, sqlErr := s.getQueryBuilder().
Insert("ChannelBookmarks").
Columns("Id", "CreateAt", "UpdateAt", "DeleteAt", "ChannelId", "OwnerId", "FileInfoId", "DisplayName", "SortOrder", "LinkUrl", "ImageUrl", "Emoji", "Type", "TargetId").
Values(bookmark.Id, bookmark.CreateAt, bookmark.UpdateAt, bookmark.DeleteAt, bookmark.ChannelId, bookmark.OwnerId, bookmark.FileId, bookmark.DisplayName, bookmark.SortOrder, bookmark.LinkUrl, bookmark.ImageUrl, bookmark.Emoji, bookmark.Type, targetID).
ToSql()
if sqlErr != nil {
return nil, errors.Wrap(err, "insert_channel_bookmark_to_sql")
}
if _, insertErr := transaction.Exec(sql, args...); insertErr != nil {
return nil, errors.Wrap(insertErr, "unable_to_save_channel_bookmark")
}
var fileInfo model.FileInfo
if bookmark.FileId != "" {
query, args, queryErr := s.getQueryBuilder().
Select("Id, Name, Extension, Size, MimeType, Width, Height, HasPreviewImage, MiniPreview").
From("FileInfo").
Where(sq.Eq{"Id": bookmark.FileId}).
ToSql()
if queryErr != nil {
return nil, errors.Wrap(queryErr, "channel_bookmark_get_file_info_to_sql")
}
if queryErr = transaction.Get(&fileInfo, query, args...); queryErr != nil {
return nil, errors.Wrap(queryErr, "unable_to_get_channel_bookmark_file_info")
}
}
err = transaction.Commit()
return bookmark.ToBookmarkWithFileInfo(&fileInfo), err
}
func (s *SqlChannelBookmarkStore) Update(bookmark *model.ChannelBookmark) error {
bookmark.PreUpdate()
if err := bookmark.IsValid(); err != nil {
return err
}
targetID := any(bookmark.TargetId)
if bookmark.TargetId == "" {
targetID = nil
}
query, args, err := s.getQueryBuilder().
Update("ChannelBookmarks").
Set("DisplayName", bookmark.DisplayName).
Set("SortOrder", bookmark.SortOrder).
Set("LinkUrl", bookmark.LinkUrl).
Set("ImageUrl", bookmark.ImageUrl).
Set("Emoji", bookmark.Emoji).
Set("FileInfoId", bookmark.FileId).
Set("TargetId", targetID).
Set("UpdateAt", bookmark.UpdateAt).
Where(sq.Eq{
"Id": bookmark.Id,
"DeleteAt": 0,
}).
ToSql()
if err != nil {
return errors.Wrap(err, "channel_bookmark_update_tosql")
}
res, err := s.GetMaster().Exec(query, args...)
if err != nil {
return errors.Wrapf(err, "failed to update channel bookmark with id=%s", bookmark.Id)
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return errors.Wrapf(err, "failed to get affected rows after updating bookmark with id=%s", bookmark.Id)
}
if rowsAffected == 0 {
return store.NewErrNotFound("ChannelBookmark", bookmark.Id)
}
return nil
}
func (s *SqlChannelBookmarkStore) UpdateSortOrder(bookmarkId, channelId string, newIndex int64) ([]*model.ChannelBookmarkWithFileInfo, error) {
now := model.GetMillis()
transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, err
}
defer finalizeTransactionX(transaction, &err)
bookmarks, err := s.GetBookmarksForChannelSince(channelId, 0)
if err != nil {
return nil, err
}
if (int(newIndex) > len(bookmarks)-1) || newIndex < 0 {
return nil, store.NewErrInvalidInput("ChannelBookmark", "SortOrder", newIndex)
}
currentIndex := -1
var current *model.ChannelBookmarkWithFileInfo
for index, b := range bookmarks {
if b.Id == bookmarkId {
currentIndex = index
current = b
break
}
}
if currentIndex == -1 {
return nil, store.NewErrNotFound("ChannelBookmark", bookmarkId)
}
bookmarks = utils.RemoveElementFromSliceAtIndex(bookmarks, currentIndex)
bookmarks = slices.Insert(bookmarks, int(newIndex), current)
caseStmt := sq.Case()
query := s.getQueryBuilder().
Update("ChannelBookmarks")
ids := []string{}
for index, b := range bookmarks {
b.SortOrder = int64(index)
b.UpdateAt = now
caseStmt = caseStmt.When(sq.Eq{"Id": b.Id}, strconv.FormatInt(int64(index), 10))
ids = append(ids, b.Id)
}
query = query.Set("SortOrder", caseStmt)
query = query.Set("UpdateAt", now)
query = query.Where(sq.Eq{"Id": ids})
queryStr, args, queryErr := query.ToSql()
if queryErr != nil {
return nil, queryErr
}
if _, updateSortOrderErr := transaction.Exec(queryStr, args...); updateSortOrderErr != nil {
return nil, updateSortOrderErr
}
err = transaction.Commit()
return bookmarks, err
}
func (s *SqlChannelBookmarkStore) Delete(bookmarkId string, deleteFile bool) error {
now := model.GetMillis()
transaction, err := s.GetMaster().Begin()
if err != nil {
return err
}
defer finalizeTransactionX(transaction, &err)
query, args, err := s.getQueryBuilder().
Update("ChannelBookmarks").
Set("DeleteAt", now).
Set("UpdateAt", now).
Where(sq.Eq{"Id": bookmarkId}).
ToSql()
if err != nil {
return errors.Wrap(err, "channel_bookmark_delete_tosql")
}
_, err = transaction.Exec(query, args...)
if err != nil {
return errors.Wrapf(err, "failed to delete channel bookmark with id=%s", bookmarkId)
}
if deleteFile {
fileIdQuery := s.getSubQueryBuilder().
Select("FileInfoId").
From("ChannelBookmarks").
Where(sq.And{
sq.Eq{"Id": bookmarkId},
})
fileQuery, fileArgs, fileErr := s.getQueryBuilder().
Update("FileInfo").
Set("DeleteAt", now).
Set("UpdateAt", now).
Where(sq.Expr("Id IN (?)", fileIdQuery)).
ToSql()
if fileErr != nil {
return errors.Wrap(err, "channel_bookmark_delete_tosql")
}
_, err = transaction.Exec(fileQuery, fileArgs...)
if err != nil {
return errors.Wrapf(err, "failed to delete channel bookmark with id=%s", bookmarkId)
}
}
return transaction.Commit()
}
func (s *SqlChannelBookmarkStore) GetBookmarksForChannelSince(channelId string, since int64) ([]*model.ChannelBookmarkWithFileInfo, error) {
query := s.getQueryBuilder().
Select(bookmarkWithFileInfoSliceColumns()...).
From("ChannelBookmarks cb").
LeftJoin("FileInfo fi ON cb.FileInfoId = fi.Id").
Where(sq.Eq{"cb.ChannelId": channelId})
if since > 0 {
query = query.Where(sq.Or{
sq.GtOrEq{"cb.UpdateAt": since},
sq.GtOrEq{"cb.DeleteAt": since},
})
} else {
query = query.Where(sq.Eq{"cb.DeleteAt": 0})
}
query = query.
OrderBy("cb.SortOrder ASC").
OrderBy("cb.DeleteAt ASC").
Limit(model.MaxBookmarksPerChannel * 2) // limit to the double of the cap as an edge case
queryString, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrap(err, "channel_bookmark_getforchanneltsince_tosql")
}
bookmarkRows := []model.ChannelBookmarkAndFileInfo{}
bookmarks := []*model.ChannelBookmarkWithFileInfo{}
if err := s.GetReplica().Select(&bookmarkRows, queryString, args...); err != nil {
return nil, errors.Wrapf(err, "failed to find bookmarks")
}
for _, bookmark := range bookmarkRows {
bookmarks = append(bookmarks, bookmark.ToChannelBookmarkWithFileInfo())
}
return bookmarks, nil
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestChannelBookmarkStore(t *testing.T) {
StoreTestWithSqlStore(t, storetest.TestChannelBookmarkStore)
}

View File

@@ -0,0 +1,83 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
type SqlChannelGuardStore struct {
*SqlStore
channelGuardSelectQuery sq.SelectBuilder
}
func newSqlChannelGuardStore(sqlStore *SqlStore) store.ChannelGuardStore {
s := &SqlChannelGuardStore{SqlStore: sqlStore}
s.channelGuardSelectQuery = s.getQueryBuilder().
Select("ChannelId", "PluginId", "CreatedAt").
From("ChannelGuards")
return s
}
func (s *SqlChannelGuardStore) Save(rctx request.CTX, guard *store.ChannelGuard) error {
builder := s.getQueryBuilder().
Insert("ChannelGuards").
Columns("ChannelId", "PluginId", "CreatedAt").
Values(guard.ChannelId, guard.PluginId, guard.CreatedAt).
SuffixExpr(sq.Expr("ON CONFLICT (ChannelId, PluginId) DO NOTHING"))
if _, err := s.GetMaster().ExecBuilder(builder); err != nil {
return errors.Wrapf(err, "failed to save channel guard for channel=%s plugin=%s", guard.ChannelId, guard.PluginId)
}
return nil
}
func (s *SqlChannelGuardStore) Delete(rctx request.CTX, channelID, pluginID string) (int64, error) {
builder := s.getQueryBuilder().
Delete("ChannelGuards").
Where(sq.Eq{
"ChannelId": channelID,
"PluginId": pluginID,
})
result, err := s.GetMaster().ExecBuilder(builder)
if err != nil {
return 0, errors.Wrapf(err, "failed to delete channel guard for channel=%s plugin=%s", channelID, pluginID)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return 0, errors.Wrapf(err, "failed to get rows affected for channel guard delete channel=%s plugin=%s", channelID, pluginID)
}
return rowsAffected, nil
}
func (s *SqlChannelGuardStore) GetForChannel(rctx request.CTX, channelID string) ([]*store.ChannelGuard, error) {
query := s.channelGuardSelectQuery.Where(sq.Eq{"ChannelId": channelID})
guards := []*store.ChannelGuard{}
if err := s.DBXFromContext(rctx.Context()).SelectBuilder(&guards, query); err != nil {
return nil, errors.Wrapf(err, "failed to get channel guards for channel=%s", channelID)
}
return guards, nil
}
func (s *SqlChannelGuardStore) GetAll(rctx request.CTX) ([]*store.ChannelGuard, error) {
guards := []*store.ChannelGuard{}
if err := s.DBXFromContext(rctx.Context()).SelectBuilder(&guards, s.channelGuardSelectQuery); err != nil {
return nil, errors.Wrap(err, "failed to get all channel guards")
}
return guards, nil
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestChannelGuardStore(t *testing.T) {
StoreTest(t, storetest.TestChannelGuardStore)
}

View File

@@ -0,0 +1,244 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/store"
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
)
const channelJoinRequestsTable = "ChannelJoinRequests"
var channelJoinRequestColumns = []string{
"Id",
"ChannelId",
"UserId",
"Message",
"Status",
"DenialReason",
"CreateAt",
"UpdateAt",
"ReviewedBy",
"ReviewedAt",
}
type SqlChannelJoinRequestStore struct {
*SqlStore
selectQuery sq.SelectBuilder
}
func newSqlChannelJoinRequestStore(sqlStore *SqlStore) store.ChannelJoinRequestStore {
s := &SqlChannelJoinRequestStore{SqlStore: sqlStore}
s.selectQuery = s.getQueryBuilder().
Select(channelJoinRequestColumns...).
From(channelJoinRequestsTable)
return s
}
func (s *SqlChannelJoinRequestStore) toMap(r *model.ChannelJoinRequest) map[string]any {
return map[string]any{
"Id": r.Id,
"ChannelId": r.ChannelId,
"UserId": r.UserId,
"Message": r.Message,
"Status": r.Status,
"DenialReason": r.DenialReason,
"CreateAt": r.CreateAt,
"UpdateAt": r.UpdateAt,
"ReviewedBy": r.ReviewedBy,
"ReviewedAt": r.ReviewedAt,
}
}
// Save inserts a new join request. The partial unique index in Postgres
// (channelid, userid) WHERE status = 'pending' enforces at-most-one pending
// row per (channel, user). On conflict we translate the unique-violation into
// a store.ErrConflict so the app layer can return 409.
func (s *SqlChannelJoinRequestStore) Save(req *model.ChannelJoinRequest) (*model.ChannelJoinRequest, error) {
req.PreSave()
if err := req.IsValid(); err != nil {
return nil, err
}
query := s.getQueryBuilder().
Insert(channelJoinRequestsTable).
SetMap(s.toMap(req))
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
if IsUniqueConstraintError(err, []string{"idx_channeljoinrequests_pending_unique"}) {
return nil, store.NewErrConflict("ChannelJoinRequest", err, "channel_id="+req.ChannelId+" user_id="+req.UserId)
}
return nil, errors.Wrap(err, "failed to save ChannelJoinRequest")
}
return req, nil
}
func (s *SqlChannelJoinRequestStore) Get(id string) (*model.ChannelJoinRequest, error) {
var req model.ChannelJoinRequest
query := s.selectQuery.Where(sq.Eq{"Id": id})
if err := s.GetReplica().GetBuilder(&req, query); err != nil {
if err == sql.ErrNoRows {
return nil, store.NewErrNotFound("ChannelJoinRequest", id)
}
return nil, errors.Wrapf(err, "failed to get ChannelJoinRequest with id=%s", id)
}
return &req, nil
}
func (s *SqlChannelJoinRequestStore) GetPendingForChannelAndUser(channelId, userId string) (*model.ChannelJoinRequest, error) {
var req model.ChannelJoinRequest
query := s.selectQuery.Where(sq.Eq{
"ChannelId": channelId,
"UserId": userId,
"Status": model.ChannelJoinRequestStatusPending,
})
if err := s.GetReplica().GetBuilder(&req, query); err != nil {
if err == sql.ErrNoRows {
return nil, store.NewErrNotFound("ChannelJoinRequest", "channel_id="+channelId+" user_id="+userId)
}
return nil, errors.Wrapf(err, "failed to get pending ChannelJoinRequest for channel_id=%s user_id=%s", channelId, userId)
}
return &req, nil
}
// applyStatusFilter applies the opts.Status filter (defaulting to pending if empty)
// to both the select and count queries. Returning the two filtered builders keeps
// list and count perfectly in sync.
func applyJoinRequestStatusFilter(opts model.GetChannelJoinRequestsOpts) sq.Eq {
status := opts.Status
if status == "" {
status = model.ChannelJoinRequestStatusPending
}
return sq.Eq{"Status": status}
}
func paginate(opts model.GetChannelJoinRequestsOpts) (limit, offset uint64) {
perPage := opts.PerPage
if perPage <= 0 {
perPage = 60
}
page := max(opts.Page, 0)
return uint64(perPage), uint64(page) * uint64(perPage)
}
func (s *SqlChannelJoinRequestStore) GetForChannel(channelId string, opts model.GetChannelJoinRequestsOpts) ([]*model.ChannelJoinRequest, int64, error) {
where := sq.And{sq.Eq{"ChannelId": channelId}, applyJoinRequestStatusFilter(opts)}
limit, offset := paginate(opts)
listQuery := s.selectQuery.
Where(where).
OrderBy("CreateAt DESC", "Id DESC").
Limit(limit).
Offset(offset)
var rows []*model.ChannelJoinRequest
if err := s.GetReplica().SelectBuilder(&rows, listQuery); err != nil {
return nil, 0, errors.Wrapf(err, "failed to list ChannelJoinRequests for channel_id=%s", channelId)
}
countQuery := s.getQueryBuilder().
Select("COUNT(*)").
From(channelJoinRequestsTable).
Where(where)
var total int64
if err := s.GetReplica().GetBuilder(&total, countQuery); err != nil {
return nil, 0, errors.Wrapf(err, "failed to count ChannelJoinRequests for channel_id=%s", channelId)
}
return rows, total, nil
}
func (s *SqlChannelJoinRequestStore) GetForUser(userId string, opts model.GetChannelJoinRequestsOpts) ([]*model.ChannelJoinRequest, int64, error) {
where := sq.And{sq.Eq{"UserId": userId}, applyJoinRequestStatusFilter(opts)}
limit, offset := paginate(opts)
listQuery := s.selectQuery.
Where(where).
OrderBy("CreateAt DESC", "Id DESC").
Limit(limit).
Offset(offset)
var rows []*model.ChannelJoinRequest
if err := s.GetReplica().SelectBuilder(&rows, listQuery); err != nil {
return nil, 0, errors.Wrapf(err, "failed to list ChannelJoinRequests for user_id=%s", userId)
}
countQuery := s.getQueryBuilder().
Select("COUNT(*)").
From(channelJoinRequestsTable).
Where(where)
var total int64
if err := s.GetReplica().GetBuilder(&total, countQuery); err != nil {
return nil, 0, errors.Wrapf(err, "failed to count ChannelJoinRequests for user_id=%s", userId)
}
return rows, total, nil
}
// Update writes the mutable fields back. Id/ChannelId/UserId/CreateAt are
// immutable post-create — the partial-unique index relies on (ChannelId, UserId)
// being stable for the lifetime of a row.
func (s *SqlChannelJoinRequestStore) Update(req *model.ChannelJoinRequest) (*model.ChannelJoinRequest, error) {
req.PreUpdate()
if err := req.IsValid(); err != nil {
return nil, err
}
query := s.getQueryBuilder().
Update(channelJoinRequestsTable).
SetMap(map[string]any{
"Status": req.Status,
"Message": req.Message,
"DenialReason": req.DenialReason,
"UpdateAt": req.UpdateAt,
"ReviewedBy": req.ReviewedBy,
"ReviewedAt": req.ReviewedAt,
}).
Where(sq.Eq{"Id": req.Id})
res, err := s.GetMaster().ExecBuilder(query)
if err != nil {
return nil, errors.Wrapf(err, "failed to update ChannelJoinRequest with id=%s", req.Id)
}
n, err := res.RowsAffected()
if err != nil {
return nil, errors.Wrap(err, "failed to read RowsAffected on ChannelJoinRequest update")
}
if n == 0 {
return nil, store.NewErrNotFound("ChannelJoinRequest", req.Id)
}
return req, nil
}
func (s *SqlChannelJoinRequestStore) CountPending(channelId string) (int64, error) {
query := s.getQueryBuilder().
Select("COUNT(*)").
From(channelJoinRequestsTable).
Where(sq.Eq{
"ChannelId": channelId,
"Status": model.ChannelJoinRequestStatusPending,
})
var count int64
if err := s.GetReplica().GetBuilder(&count, query); err != nil {
return 0, errors.Wrapf(err, "failed to count pending ChannelJoinRequests for channel_id=%s", channelId)
}
return count, nil
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestChannelJoinRequestStore(t *testing.T) {
StoreTest(t, storetest.TestChannelJoinRequestStore)
}

View File

@@ -0,0 +1,380 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"fmt"
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
"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"
)
type SqlChannelMemberHistoryStore struct {
*SqlStore
channelMemberHistoryQuery sq.SelectBuilder
}
func newSqlChannelMemberHistoryStore(sqlStore *SqlStore) store.ChannelMemberHistoryStore {
s := &SqlChannelMemberHistoryStore{
SqlStore: sqlStore,
}
s.channelMemberHistoryQuery = s.getQueryBuilder().
Select(
"ChannelMemberHistory.ChannelId",
"ChannelMemberHistory.UserId",
"ChannelMemberHistory.JoinTime",
"ChannelMemberHistory.LeaveTime",
).
From("ChannelMemberHistory")
return s
}
func (s SqlChannelMemberHistoryStore) LogJoinEvent(userId string, channelId string, joinTime int64) error {
channelMemberHistory := &model.ChannelMemberHistory{
UserId: userId,
ChannelId: channelId,
JoinTime: joinTime,
}
if _, err := s.GetMaster().NamedExec(`INSERT INTO ChannelMemberHistory
(UserId, ChannelId, JoinTime)
VALUES
(:UserId, :ChannelId, :JoinTime)`, channelMemberHistory); err != nil {
return errors.Wrapf(err, "LogJoinEvent userId=%s channelId=%s joinTime=%d", userId, channelId, joinTime)
}
return nil
}
func (s SqlChannelMemberHistoryStore) LogLeaveEvent(rctx request.CTX, userId string, channelId string, leaveTime int64) error {
query, params, err := s.getQueryBuilder().
Update("ChannelMemberHistory").
Set("LeaveTime", leaveTime).
Where(sq.And{
sq.Eq{"UserId": userId},
sq.Eq{"ChannelId": channelId},
sq.Eq{"LeaveTime": nil},
}).ToSql()
if err != nil {
return errors.Wrap(err, "channel_member_history_to_sql")
}
sqlResult, err := s.GetMaster().Exec(query, params...)
if err != nil {
return errors.Wrapf(err, "LogLeaveEvent userId=%s channelId=%s leaveTime=%d", userId, channelId, leaveTime)
}
if rows, err := sqlResult.RowsAffected(); err == nil && rows != 1 {
// there was no join event to update - this is best effort, so no need to raise an error
rctx.Logger().Warn("Channel join event for user and channel not found", mlog.String("user", userId), mlog.String("channel", channelId))
}
return nil
}
// GetEverMembersInChannel returns user IDs that have at least one membership history row in the channel.
func (s SqlChannelMemberHistoryStore) GetEverMembersInChannel(channelID string, userIDs []string) ([]string, error) {
if len(userIDs) == 0 {
return []string{}, nil
}
query, args, err := s.getQueryBuilder().
Select("UserId").
Distinct().
From("ChannelMemberHistory").
Where(sq.And{
sq.Eq{"ChannelId": channelID},
sq.Eq{"UserId": userIDs},
}).
OrderBy("UserId ASC").
ToSql()
if err != nil {
return nil, errors.Wrap(err, "channel_member_history_to_sql")
}
everMembers := []string{}
if err := s.GetReplica().Select(&everMembers, query, args...); err != nil {
return nil, errors.Wrapf(err, "GetEverMembersInChannel channelId=%s users=%d", channelID, len(userIDs))
}
return everMembers, nil
}
func (s SqlChannelMemberHistoryStore) GetChannelsWithActivityDuring(startTime int64, endTime int64) ([]string, error) {
// ChannelMemberHistory has been in production for long enough that we are assuming the export period
// starts after the ChannelMemberHistory table was first introduced
subqueryPosts := s.getSubQueryBuilder().
Select("p.ChannelId AS ChannelId").
Distinct().
From("Posts AS p").
Where(
sq.And{
sq.GtOrEq{"p.UpdateAt": startTime},
sq.LtOrEq{"p.UpdateAt": endTime},
sq.NotLike{"p.Type": "system_%"},
})
subqueryCMH := s.getSubQueryBuilder().
Select("cmh.ChannelId AS ChannelId").
Distinct().
From("ChannelMemberHistory AS cmh").
Where(
sq.Or{
sq.And{
sq.GtOrEq{"cmh.JoinTime": startTime},
sq.LtOrEq{"cmh.JoinTime": endTime},
},
sq.And{
sq.GtOrEq{"cmh.LeaveTime": startTime},
sq.LtOrEq{"cmh.LeaveTime": endTime},
},
})
unionExpr, args, err := sq.Expr("(? UNION ?) AS cm", subqueryPosts, subqueryCMH).ToSql()
if err != nil {
return nil, errors.Wrap(err, "GetChannelsWithActivityDuring unionExpr to sql")
}
query, _, err := s.getQueryBuilder().
Select("ChannelId").
From(unionExpr).ToSql()
if err != nil {
return nil, errors.Wrap(err, "GetChannelsWithActivityDuring query to sql")
}
channelIds := make([]string, 0)
if err := s.GetReplica().Select(&channelIds, query, args...); err != nil {
return nil, err
}
return channelIds, nil
}
func (s SqlChannelMemberHistoryStore) GetUsersInChannelDuring(startTime int64, endTime int64, channelIds []string) ([]*model.ChannelMemberHistoryResult, error) {
useChannelMemberHistory, err := s.hasDataAtOrBefore(startTime)
if err != nil {
return nil, errors.Wrapf(err, "hasDataAtOrBefore startTime=%d endTime=%d channelId=%v", startTime, endTime, channelIds)
}
if useChannelMemberHistory {
// the export period starts after the ChannelMemberHistory table was first introduced, so we can use the
// data from it for our export
channelMemberHistories, err2 := s.getFromChannelMemberHistoryTable(startTime, endTime, channelIds)
if err2 != nil {
return nil, errors.Wrapf(err2, "getFromChannelMemberHistoryTable startTime=%d endTime=%d channelId=%v", startTime, endTime, channelIds)
}
return channelMemberHistories, nil
}
// the export period starts before the ChannelMemberHistory table was introduced, so we need to fake the
// data by assuming that anybody who has ever joined the channel in question was present during the export period.
// this may not always be true, but it's better than saying that somebody wasn't there when they were
channelMemberHistories, err := s.getFromChannelMembersTable(startTime, endTime, channelIds)
if err != nil {
return nil, errors.Wrapf(err, "getFromChannelMembersTable startTime=%d endTime=%d channelId=%v", startTime, endTime, channelIds)
}
return channelMemberHistories, nil
}
func (s SqlChannelMemberHistoryStore) hasDataAtOrBefore(time int64) (bool, error) {
type NullableCountResult struct {
Min sql.NullInt64
}
query, _, err := s.getQueryBuilder().Select("MIN(JoinTime) as Min").From("ChannelMemberHistory").ToSql()
if err != nil {
return false, errors.Wrap(err, "channel_member_history_to_sql")
}
var result NullableCountResult
if err := s.GetReplica().Get(&result, query); err != nil {
return false, err
} else if result.Min.Valid {
return result.Min.Int64 <= time, nil
}
// if the result was null, there are no rows in the table, so there is no data from before
return false, nil
}
func (s SqlChannelMemberHistoryStore) getFromChannelMemberHistoryTable(startTime int64, endTime int64, channelIds []string) ([]*model.ChannelMemberHistoryResult, error) {
query := s.channelMemberHistoryQuery.
Column("u.Email AS \"Email\"").
Column("u.Username").
Column("Bots.UserId IS NOT NULL AS IsBot").
Column("u.DeleteAt AS UserDeleteAt").
Join("Users u ON ChannelMemberHistory.UserId = u.Id").
LeftJoin("Bots ON Bots.UserId = u.Id").
Where(sq.And{
sq.Eq{"ChannelMemberHistory.ChannelId": channelIds},
sq.LtOrEq{"ChannelMemberHistory.JoinTime": endTime},
sq.Or{
sq.Eq{"ChannelMemberHistory.LeaveTime": nil},
sq.GtOrEq{"ChannelMemberHistory.LeaveTime": startTime},
},
}).
OrderBy("ChannelMemberHistory.JoinTime ASC")
queryString, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrap(err, "channel_member_history_to_sql")
}
histories := []*model.ChannelMemberHistoryResult{}
if err := s.GetReplica().Select(&histories, queryString, args...); err != nil {
return nil, err
}
return histories, nil
}
func (s SqlChannelMemberHistoryStore) getFromChannelMembersTable(startTime int64, endTime int64, channelIds []string) ([]*model.ChannelMemberHistoryResult, error) {
query, args, err := s.getQueryBuilder().
Select(`ch.ChannelId, ch.UserId, u.Email AS "Email", u.Username, Bots.UserId IS NOT NULL AS IsBot, u.DeleteAt AS UserDeleteAt`).
Distinct().
From("ChannelMembers ch").
Join("Users u ON ch.UserId = u.id").
LeftJoin("Bots ON Bots.UserId = u.id").
Where(sq.Eq{"ch.ChannelId": channelIds}).ToSql()
if err != nil {
return nil, errors.Wrap(err, "channel_member_history_to_sql")
}
histories := []*model.ChannelMemberHistoryResult{}
if err := s.GetReplica().Select(&histories, query, args...); err != nil {
return nil, err
}
// we have to fill in the join/leave times, because that data doesn't exist in the channel members table
for _, channelMemberHistory := range histories {
channelMemberHistory.JoinTime = startTime
channelMemberHistory.LeaveTime = new(endTime)
}
return histories, nil
}
// PermanentDeleteBatchForRetentionPolicies deletes a batch of records which are affected by
// the global or a granular retention policy.
// See `genericPermanentDeleteBatchForRetentionPolicies` for details.
func (s SqlChannelMemberHistoryStore) PermanentDeleteBatchForRetentionPolicies(retentionPolicyBatchConfigs model.RetentionPolicyBatchConfigs, cursor model.RetentionPolicyCursor) (int64, model.RetentionPolicyCursor, error) {
builder := s.getQueryBuilder().
Select("ChannelMemberHistory.ChannelId, ChannelMemberHistory.UserId, ChannelMemberHistory.JoinTime").
From("ChannelMemberHistory")
return genericPermanentDeleteBatchForRetentionPolicies(RetentionPolicyBatchDeletionInfo{
BaseBuilder: builder,
Table: "ChannelMemberHistory",
TimeColumn: "LeaveTime",
PrimaryKeys: []string{"ChannelId", "UserId", "JoinTime"},
ChannelIDTable: "ChannelMemberHistory",
NowMillis: retentionPolicyBatchConfigs.Now,
GlobalPolicyEndTime: retentionPolicyBatchConfigs.GlobalPolicyEndTime,
Limit: retentionPolicyBatchConfigs.Limit,
StoreDeletedIds: false,
}, s.SqlStore, cursor)
}
// DeleteOrphanedRows removes entries from ChannelMemberHistory when a corresponding channel no longer exists.
func (s SqlChannelMemberHistoryStore) DeleteOrphanedRows(limit int) (deleted int64, err error) {
const query = `
DELETE FROM ChannelMemberHistory WHERE ctid IN (
SELECT ChannelMemberHistory.ctid FROM ChannelMemberHistory
LEFT JOIN Channels ON ChannelMemberHistory.ChannelId = Channels.Id
WHERE Channels.Id IS NULL
LIMIT $1
)`
result, err := s.GetMaster().Exec(query, limit)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
func (s SqlChannelMemberHistoryStore) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) {
innerSelect, args, err := s.getQueryBuilder().
Select("ctid").
From("ChannelMemberHistory").
Where(sq.And{
sq.NotEq{"LeaveTime": nil},
sq.LtOrEq{"LeaveTime": endTime},
}).Limit(uint64(limit)).
ToSql()
if err != nil {
return 0, errors.Wrap(err, "channel_member_history_to_sql")
}
query, _, err := s.getQueryBuilder().
Delete("ChannelMemberHistory").
Where(fmt.Sprintf(
"ctid IN (%s)", innerSelect,
)).ToSql()
if err != nil {
return 0, errors.Wrap(err, "channel_member_history_to_sql")
}
sqlResult, err := s.GetMaster().Exec(query, args...)
if err != nil {
return 0, errors.Wrapf(err, "PermanentDeleteBatch endTime=%d limit=%d", endTime, limit)
}
rowsAffected, err := sqlResult.RowsAffected()
if err != nil {
return 0, errors.Wrapf(err, "PermanentDeleteBatch endTime=%d limit=%d", endTime, limit)
}
return rowsAffected, nil
}
// GetMembershipChanges returns all membership events (joins and leaves) for a channel since the given timestamp.
// Uses inclusive comparison (>=) so events at the cursor timestamp are re-fetched rather than lost at batch
// boundaries. This may cause redundant re-sends when consecutive batches share a boundary timestamp, but the
// receiver is idempotent so duplicates are harmless. A composite cursor (timestamp + ID) like posts use would
// eliminate duplicates, but would require a schema change to SharedChannelRemotes; the current trade-off avoids that.
func (s SqlChannelMemberHistoryStore) GetMembershipChanges(channelID string, since int64, limit int) ([]*model.ChannelMemberHistory, error) {
query, args, err := s.getQueryBuilder().
Select("ChannelId", "UserId", "JoinTime", "LeaveTime").
From("ChannelMemberHistory").
Where(sq.And{
sq.Eq{"ChannelId": channelID},
sq.Or{
sq.GtOrEq{"JoinTime": since},
sq.And{
sq.NotEq{"LeaveTime": nil},
sq.GtOrEq{"LeaveTime": since},
},
},
}).
OrderBy("GREATEST(JoinTime, COALESCE(LeaveTime, 0)) ASC", "UserId ASC").
Limit(uint64(limit)).
ToSql()
if err != nil {
return nil, errors.Wrap(err, "channel_member_history_to_sql")
}
histories := []*model.ChannelMemberHistory{}
if err := s.GetReplica().Select(&histories, query, args...); err != nil {
return nil, errors.Wrapf(err, "GetMembershipChanges channelId=%s since=%d limit=%d", channelID, since, limit)
}
return histories, nil
}
// GetChannelsLeftSince returns list of channels that the user has left after a given time,
// but has not rejoined again.
func (s SqlChannelMemberHistoryStore) GetChannelsLeftSince(userID string, since int64) ([]string, error) {
query, params, err := s.getQueryBuilder().
Select("ChannelId").
From("ChannelMemberHistory").
GroupBy("ChannelId").
Where(sq.Eq{"UserId": userID}).
Having("MAX(LeaveTime) > MAX(JoinTime) AND MAX(LeaveTime) IS NOT NULL AND MAX(LeaveTime) >= ?", since).ToSql()
if err != nil {
return nil, errors.Wrap(err, "channel_member_history_to_sql")
}
channelIds := []string{}
err = s.GetReplica().Select(&channelIds, query, params...)
if err != nil {
return nil, errors.Wrapf(err, "GetChannelsLeftSince userId=%s since=%d", userID, since)
}
return channelIds, nil
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestChannelMemberHistoryStore(t *testing.T) {
StoreTest(t, storetest.TestChannelMemberHistoryStore)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestChannelStoreCategories(t *testing.T) {
StoreTestWithSqlStore(t, storetest.TestChannelStoreCategories)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,156 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
type sqlClusterDiscoveryStore struct {
*SqlStore
clusterDiscoveryQuery sq.SelectBuilder
}
func newSqlClusterDiscoveryStore(sqlStore *SqlStore) store.ClusterDiscoveryStore {
s := &sqlClusterDiscoveryStore{
SqlStore: sqlStore,
}
s.clusterDiscoveryQuery = s.getQueryBuilder().
Select(
"Id",
"Type",
"ClusterName",
"Hostname",
"GossipPort",
"Port",
"CreateAt",
"LastPingAt",
).
From("ClusterDiscovery")
return s
}
func (s sqlClusterDiscoveryStore) Save(ClusterDiscovery *model.ClusterDiscovery) error {
ClusterDiscovery.PreSave()
if err := ClusterDiscovery.IsValid(); err != nil {
return err
}
if _, err := s.GetMaster().NamedExec(`
INSERT INTO
ClusterDiscovery
(Id, Type, ClusterName, Hostname, GossipPort, Port, CreateAt, LastPingAt)
VALUES
(:Id, :Type, :ClusterName, :Hostname, :GossipPort, :Port, :CreateAt, :LastPingAt)
`, ClusterDiscovery); err != nil {
return errors.Wrap(err, "failed to save ClusterDiscovery")
}
return nil
}
func (s sqlClusterDiscoveryStore) Delete(ClusterDiscovery *model.ClusterDiscovery) (bool, error) {
query := s.getQueryBuilder().
Delete("ClusterDiscovery").
Where(sq.Eq{"Type": ClusterDiscovery.Type}).
Where(sq.Eq{"ClusterName": ClusterDiscovery.ClusterName}).
Where(sq.Eq{"Hostname": ClusterDiscovery.Hostname})
queryString, args, err := query.ToSql()
if err != nil {
return false, errors.Wrap(err, "cluster_discovery_tosql")
}
res, err := s.GetMaster().Exec(queryString, args...)
if err != nil {
return false, errors.Wrap(err, "failed to delete ClusterDiscovery")
}
count, err := res.RowsAffected()
if err != nil {
return false, errors.Wrap(err, "failed to count rows affected")
}
return count != 0, nil
}
func (s sqlClusterDiscoveryStore) Exists(ClusterDiscovery *model.ClusterDiscovery) (bool, error) {
query := s.getQueryBuilder().
Select("COUNT(*)").
From("ClusterDiscovery").
Where(sq.Eq{"Type": ClusterDiscovery.Type}).
Where(sq.Eq{"ClusterName": ClusterDiscovery.ClusterName}).
Where(sq.Eq{"Hostname": ClusterDiscovery.Hostname})
queryString, args, err := query.ToSql()
if err != nil {
return false, errors.Wrap(err, "cluster_discovery_tosql")
}
var count int
if err := s.GetMaster().Get(&count, queryString, args...); err != nil {
return false, errors.Wrap(err, "failed to count ClusterDiscovery")
}
return count != 0, nil
}
func (s sqlClusterDiscoveryStore) GetAll(ClusterDiscoveryType, clusterName string) ([]*model.ClusterDiscovery, error) {
query := s.clusterDiscoveryQuery.
Where(sq.Eq{"Type": ClusterDiscoveryType}).
Where(sq.Eq{"ClusterName": clusterName}).
Where(sq.Gt{"LastPingAt": model.GetMillis() - model.CDSOfflineAfterMillis})
queryString, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrap(err, "cluster_discovery_tosql")
}
list := []*model.ClusterDiscovery{}
if err := s.GetMaster().Select(&list, queryString, args...); err != nil {
return nil, errors.Wrap(err, "failed to find ClusterDiscovery")
}
return list, nil
}
func (s sqlClusterDiscoveryStore) SetLastPingAt(ClusterDiscovery *model.ClusterDiscovery) error {
query := s.getQueryBuilder().
Update("ClusterDiscovery").
Set("LastPingAt", model.GetMillis()).
Where(sq.Eq{"Type": ClusterDiscovery.Type}).
Where(sq.Eq{"ClusterName": ClusterDiscovery.ClusterName}).
Where(sq.Eq{"Hostname": ClusterDiscovery.Hostname})
queryString, args, err := query.ToSql()
if err != nil {
return errors.Wrap(err, "cluster_discovery_tosql")
}
if _, err := s.GetMaster().Exec(queryString, args...); err != nil {
return errors.Wrap(err, "failed to update ClusterDiscovery")
}
return nil
}
func (s sqlClusterDiscoveryStore) Cleanup() error {
query := s.getQueryBuilder().
Delete("ClusterDiscovery").
Where(sq.Lt{"LastPingAt": model.GetMillis() - model.CDSOfflineAfterMillis})
queryString, args, err := query.ToSql()
if err != nil {
return errors.Wrap(err, "cluster_discovery_tosql")
}
if _, err := s.GetMaster().Exec(queryString, args...); err != nil {
return errors.Wrap(err, "failed to delete ClusterDiscoveries")
}
return nil
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestClusterDiscoveryStore(t *testing.T) {
StoreTest(t, storetest.TestClusterDiscoveryStore)
}

View File

@@ -0,0 +1,261 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"fmt"
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
type SqlCommandStore struct {
*SqlStore
commandColumns []string
commandsQuery sq.SelectBuilder
}
func newSqlCommandStore(sqlStore *SqlStore) store.CommandStore {
s := &SqlCommandStore{SqlStore: sqlStore}
s.commandColumns = []string{
"Id",
"Token",
"CreateAt",
"UpdateAt",
"DeleteAt",
"CreatorId",
"TeamId",
s.toReserveCase("trigger"),
"Method",
"Username",
"IconURL",
"AutoComplete",
"AutoCompleteDesc",
"AutoCompleteHint",
"DisplayName",
"Description",
"URL",
"PluginId",
}
s.commandsQuery = s.getQueryBuilder().
Select(s.commandColumns...).
From("Commands")
return s
}
func (s SqlCommandStore) Save(command *model.Command) (*model.Command, error) {
if command.Id != "" {
return nil, store.NewErrInvalidInput("Command", "CommandId", command.Id)
}
command.PreSave()
if err := command.IsValid(); err != nil {
return nil, err
}
insertQuery := s.getQueryBuilder().
Insert("Commands").
Columns(s.commandColumns...).
Values(
command.Id,
command.Token,
command.CreateAt,
command.UpdateAt,
command.DeleteAt,
command.CreatorId,
command.TeamId,
command.Trigger,
command.Method,
command.Username,
command.IconURL,
command.AutoComplete,
command.AutoCompleteDesc,
command.AutoCompleteHint,
command.DisplayName,
command.Description,
command.URL,
command.PluginId,
)
if _, err := s.GetMaster().ExecBuilder(insertQuery); err != nil {
return nil, errors.Wrapf(err, "insert: command_id=%s", command.Id)
}
return command, nil
}
func (s SqlCommandStore) Get(id string) (*model.Command, error) {
var command model.Command
query, args, err := s.commandsQuery.
Where(sq.Eq{"Id": id, "DeleteAt": 0}).ToSql()
if err != nil {
return nil, errors.Wrapf(err, "commands_tosql")
}
if err = s.GetReplica().Get(&command, query, args...); err == sql.ErrNoRows {
return nil, store.NewErrNotFound("Command", id)
} else if err != nil {
return nil, errors.Wrapf(err, "selectone: command_id=%s", id)
}
return &command, nil
}
func (s SqlCommandStore) GetByTeam(teamId string) ([]*model.Command, error) {
commands := []*model.Command{}
sql, args, err := s.commandsQuery.
Where(sq.Eq{"TeamId": teamId, "DeleteAt": 0}).ToSql()
if err != nil {
return nil, errors.Wrapf(err, "commands_tosql")
}
if err := s.GetReplica().Select(&commands, sql, args...); err != nil {
return nil, errors.Wrapf(err, "select: team_id=%s", teamId)
}
return commands, nil
}
func (s SqlCommandStore) GetByTrigger(teamId string, trigger string) (*model.Command, error) {
var command model.Command
query, args, err := s.commandsQuery.
Where(sq.Eq{"TeamId": teamId, "DeleteAt": 0, "\"trigger\"": trigger}).ToSql()
if err != nil {
return nil, errors.Wrapf(err, "commands_tosql")
}
if err := s.GetReplica().Get(&command, query, args...); err == sql.ErrNoRows {
errorId := "teamId=" + teamId + ", trigger=" + trigger
return nil, store.NewErrNotFound("Command", errorId)
} else if err != nil {
return nil, errors.Wrapf(err, "selectone: team_id=%s, trigger=%s", teamId, trigger)
}
return &command, nil
}
func (s SqlCommandStore) Delete(commandId string, time int64) error {
sql, args, err := s.getQueryBuilder().
Update("Commands").
SetMap(sq.Eq{"DeleteAt": time, "UpdateAt": time}).
Where(sq.Eq{"Id": commandId}).ToSql()
if err != nil {
return errors.Wrapf(err, "commands_tosql")
}
_, err = s.GetMaster().Exec(sql, args...)
if err != nil {
errors.Wrapf(err, "delete: command_id=%s", commandId)
}
return nil
}
func (s SqlCommandStore) PermanentDeleteByTeam(teamId string) error {
sql, args, err := s.getQueryBuilder().
Delete("Commands").
Where(sq.Eq{"TeamId": teamId}).ToSql()
if err != nil {
return errors.Wrapf(err, "commands_tosql")
}
_, err = s.GetMaster().Exec(sql, args...)
if err != nil {
return errors.Wrapf(err, "delete: team_id=%s", teamId)
}
return nil
}
func (s SqlCommandStore) PermanentDeleteByUser(userId string) error {
sql, args, err := s.getQueryBuilder().
Delete("Commands").
Where(sq.Eq{"CreatorId": userId}).ToSql()
if err != nil {
return errors.Wrapf(err, "commands_tosql")
}
_, err = s.GetMaster().Exec(sql, args...)
if err != nil {
return errors.Wrapf(err, "delete: user_id=%s", userId)
}
return nil
}
func (s SqlCommandStore) Update(cmd *model.Command) (*model.Command, error) {
cmd.UpdateAt = model.GetMillis()
if err := cmd.IsValid(); err != nil {
return nil, err
}
query := s.getQueryBuilder().
Update("Commands").
Set("Token", cmd.Token).
Set("CreateAt", cmd.CreateAt).
Set("UpdateAt", cmd.UpdateAt).
Set("CreatorId", cmd.CreatorId).
Set("TeamId", cmd.TeamId).
Set("Method", cmd.Method).
Set("Username", cmd.Username).
Set("IconURL", cmd.IconURL).
Set("AutoComplete", cmd.AutoComplete).
Set("AutoCompleteDesc", cmd.AutoCompleteDesc).
Set("AutoCompleteHint", cmd.AutoCompleteHint).
Set("DisplayName", cmd.DisplayName).
Set("Description", cmd.Description).
Set("URL", cmd.URL).
Set("PluginId", cmd.PluginId).
Where(sq.Eq{"Id": cmd.Id})
// Trigger is a keyword
query = query.Set(s.toReserveCase("trigger"), cmd.Trigger)
queryString, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrap(err, "commands_tosql")
}
res, err := s.GetMaster().Exec(queryString, args...)
if err != nil {
return nil, errors.Wrap(err, "failed to update commands")
}
count, err := res.RowsAffected()
if err != nil {
return nil, errors.Wrap(err, "error while getting rows_affected")
}
if count > 1 {
return nil, fmt.Errorf("unexpected count while updating commands: count=%d, Id=%s", count, cmd.Id)
}
return cmd, nil
}
func (s SqlCommandStore) AnalyticsCommandCount(teamId string) (int64, error) {
query := s.getQueryBuilder().
Select("COUNT(*)").
From("Commands").
Where(sq.Eq{"DeleteAt": 0})
if teamId != "" {
query = query.Where(sq.Eq{"TeamId": teamId})
}
sql, args, err := query.ToSql()
if err != nil {
return 0, errors.Wrapf(err, "commands_tosql")
}
var c int64
err = s.GetReplica().Get(&c, sql, args...)
if err != nil {
return 0, errors.Wrapf(err, "unable to count the commands: team_id=%s", teamId)
}
return c, nil
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestCommandStore(t *testing.T) {
StoreTest(t, storetest.TestCommandStore)
}

View File

@@ -0,0 +1,137 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
type SqlCommandWebhookStore struct {
*SqlStore
commandWebhookColumns []string
commandWebhookQuery sq.SelectBuilder
}
func newSqlCommandWebhookStore(sqlStore *SqlStore) store.CommandWebhookStore {
s := &SqlCommandWebhookStore{
SqlStore: sqlStore,
}
s.commandWebhookColumns = []string{
"Id",
"CreateAt",
"CommandId",
"UserId",
"ChannelId",
"RootId",
"UseCount",
}
s.commandWebhookQuery = s.getQueryBuilder().
Select(s.commandWebhookColumns...).
From("CommandWebhooks")
return s
}
func (s SqlCommandWebhookStore) Save(webhook *model.CommandWebhook) (*model.CommandWebhook, error) {
if webhook.Id != "" {
return nil, store.NewErrInvalidInput("CommandWebhook", "id", webhook.Id)
}
webhook.PreSave()
if err := webhook.IsValid(); err != nil {
return nil, err
}
insertQuery := s.getQueryBuilder().
Insert("CommandWebhooks").
Columns(s.commandWebhookColumns...).
Values(
webhook.Id,
webhook.CreateAt,
webhook.CommandId,
webhook.UserId,
webhook.ChannelId,
webhook.RootId,
webhook.UseCount,
)
if _, err := s.GetMaster().ExecBuilder(insertQuery); err != nil {
return nil, errors.Wrapf(err, "save: id=%s", webhook.Id)
}
return webhook, nil
}
func (s SqlCommandWebhookStore) Get(id string) (*model.CommandWebhook, error) {
var webhook model.CommandWebhook
exptime := model.GetMillis() - model.CommandWebhookLifetime
query := s.commandWebhookQuery.
Where(sq.Eq{"Id": id}).
Where(sq.Gt{"CreateAt": exptime})
queryString, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrap(err, "get_tosql")
}
if err := s.GetReplica().Get(&webhook, queryString, args...); err != nil {
if err == sql.ErrNoRows {
return nil, store.NewErrNotFound("CommandWebhook", id)
}
return nil, errors.Wrapf(err, "get: id=%s", id)
}
return &webhook, nil
}
func (s SqlCommandWebhookStore) TryUse(id string, limit int) error {
query := s.getQueryBuilder().
Update("CommandWebhooks").
Set("UseCount", sq.Expr("UseCount + 1")).
Where(sq.Eq{"Id": id}).
Where(sq.Lt{"UseCount": limit})
queryString, args, err := query.ToSql()
if err != nil {
return errors.Wrap(err, "tryuse_tosql")
}
if sqlResult, err := s.GetMaster().Exec(queryString, args...); err != nil {
return errors.Wrapf(err, "tryuse: id=%s limit=%d", id, limit)
} else if rows, err := sqlResult.RowsAffected(); rows == 0 {
return store.NewErrInvalidInput("CommandWebhook", "id", id).Wrap(err)
}
return nil
}
func (s SqlCommandWebhookStore) Cleanup() {
exptime := model.GetMillis() - model.CommandWebhookLifetime
query := s.getQueryBuilder().
Delete("CommandWebhooks").
Where(sq.Lt{"CreateAt": exptime})
queryString, args, err := query.ToSql()
if err != nil {
mlog.Error("Failed to build query when trying to perform a cleanup in command webhook store.")
return
}
if _, err := s.GetMaster().Exec(queryString, args...); err != nil {
mlog.Error("Unable to cleanup command webhook store.")
}
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestCommandWebhookStore(t *testing.T) {
StoreTest(t, storetest.TestCommandWebhookStore)
}

View File

@@ -0,0 +1,372 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"fmt"
"strings"
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
type SqlComplianceStore struct {
*SqlStore
tableSelectQuery sq.SelectBuilder
}
func newSqlComplianceStore(sqlStore *SqlStore) store.ComplianceStore {
s := SqlComplianceStore{
SqlStore: sqlStore,
}
s.tableSelectQuery = s.
getQueryBuilder().
Select(
"Id",
"CreateAt",
"UserId",
"Status",
"Count",
s.toReserveCase("desc"),
"Type",
"StartAt",
"EndAt",
"Keywords",
"Emails",
).
From("Compliances")
return &s
}
func (s SqlComplianceStore) Save(compliance *model.Compliance) (*model.Compliance, error) {
compliance.PreSave()
if err := compliance.IsValid(); err != nil {
return nil, err
}
// DESC is a keyword
desc := s.toReserveCase("desc")
query := `INSERT INTO Compliances (Id, CreateAt, UserId, Status, Count, ` + desc + `, Type, StartAt, EndAt, Keywords, Emails)
VALUES
(:Id, :CreateAt, :UserId, :Status, :Count, :Desc, :Type, :StartAt, :EndAt, :Keywords, :Emails)`
if _, err := s.GetMaster().NamedExec(query, compliance); err != nil {
return nil, errors.Wrap(err, "failed to save Compliance")
}
return compliance, nil
}
func (s SqlComplianceStore) Update(compliance *model.Compliance) (*model.Compliance, error) {
if err := compliance.IsValid(); err != nil {
return nil, err
}
query := s.getQueryBuilder().
Update("Compliances").
Set("CreateAt", compliance.CreateAt).
Set("UserId", compliance.UserId).
Set("Status", compliance.Status).
Set("Count", compliance.Count).
Set("Type", compliance.Type).
Set("StartAt", compliance.StartAt).
Set("EndAt", compliance.EndAt).
Set("Keywords", compliance.Keywords).
Set("Emails", compliance.Emails).
Where(sq.Eq{"Id": compliance.Id})
// DESC is a keyword
query = query.Set(s.toReserveCase("desc"), compliance.Desc)
queryString, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrap(err, "compliances_tosql")
}
res, err := s.GetMaster().Exec(queryString, args...)
if err != nil {
return nil, errors.Wrap(err, "failed to update Compliance")
}
count, err := res.RowsAffected()
if err != nil {
return nil, errors.Wrap(err, "error while getting rows_affected")
}
if count > 1 {
return nil, fmt.Errorf("unexpected count while updating compliances: count=%d, Id=%s", count, compliance.Id)
}
return compliance, nil
}
func (s SqlComplianceStore) GetAll(offset, limit int) (model.Compliances, error) {
query := s.tableSelectQuery.
OrderBy("CreateAt DESC").
Limit(uint64(limit)).
Offset(uint64(offset))
var compliances model.Compliances
if err := s.GetReplica().SelectBuilder(&compliances, query); err != nil {
return nil, errors.Wrap(err, "failed to find all Compliances")
}
return compliances, nil
}
func (s SqlComplianceStore) Get(id string) (*model.Compliance, error) {
query := s.tableSelectQuery.Where(sq.Eq{"Id": id})
var compliance model.Compliance
if err := s.GetReplica().GetBuilder(&compliance, query); err != nil {
if err == sql.ErrNoRows {
return nil, store.NewErrNotFound("Compliances", id)
}
return nil, errors.Wrapf(err, "failed to get Compliance with id=%s", id)
}
if compliance.Id == "" {
return nil, store.NewErrNotFound("Compliance", id)
}
return &compliance, nil
}
func (s SqlComplianceStore) ComplianceExport(job *model.Compliance, cursor model.ComplianceExportCursor, limit int) ([]*model.CompliancePost, model.ComplianceExportCursor, error) {
keywordQuery := ""
var argsKeywords []any
keywords := strings.Fields(strings.TrimSpace(strings.ToLower(strings.Replace(job.Keywords, ",", " ", -1))))
if len(keywords) > 0 {
clauses := make([]string, len(keywords))
for i, keyword := range keywords {
keyword = sanitizeSearchTerm(keyword, "\\")
clauses[i] = "LOWER(Posts.Message) LIKE ?"
argsKeywords = append(argsKeywords, "%"+keyword+"%")
}
keywordQuery = "AND (" + strings.Join(clauses, " OR ") + ")"
}
emailQuery := ""
var argsEmails []any
emails := strings.Fields(strings.TrimSpace(strings.ToLower(strings.Replace(job.Emails, ",", " ", -1))))
if len(emails) > 0 {
clauses := make([]string, len(emails))
for i, email := range emails {
clauses[i] = "Users.Email = ?"
argsEmails = append(argsEmails, email)
}
emailQuery = "AND (" + strings.Join(clauses, " OR ") + ")"
}
// The idea is to first iterate over the channel posts, and then when we run out of those,
// start iterating over the direct message posts.
channelPosts := []*model.CompliancePost{}
channelsQuery := ""
var argsChannelsQuery []any
if !cursor.ChannelsQueryCompleted {
if cursor.LastChannelsQueryPostCreateAt == 0 {
cursor.LastChannelsQueryPostCreateAt = job.StartAt
}
// append the named parameters of SQL query in the correct order to argsChannelsQuery
argsChannelsQuery = append(argsChannelsQuery, cursor.LastChannelsQueryPostCreateAt, cursor.LastChannelsQueryPostCreateAt, cursor.LastChannelsQueryPostID, job.EndAt)
argsChannelsQuery = append(argsChannelsQuery, argsEmails...)
argsChannelsQuery = append(argsChannelsQuery, argsKeywords...)
argsChannelsQuery = append(argsChannelsQuery, limit)
channelsQuery = `
SELECT
Teams.Name AS TeamName,
Teams.DisplayName AS TeamDisplayName,
Channels.Name AS ChannelName,
Channels.DisplayName AS ChannelDisplayName,
Channels.Type AS ChannelType,
Users.Username AS UserUsername,
Users.Email AS UserEmail,
Users.Nickname AS UserNickname,
Posts.Id AS PostId,
Posts.CreateAt AS PostCreateAt,
Posts.UpdateAt AS PostUpdateAt,
Posts.DeleteAt AS PostDeleteAt,
Posts.RootId AS PostRootId,
Posts.OriginalId AS PostOriginalId,
Posts.Message AS PostMessage,
Posts.Type AS PostType,
Posts.Props AS PostProps,
Posts.Hashtags AS PostHashtags,
Posts.FileIds AS PostFileIds,
Bots.UserId IS NOT NULL AS IsBot
FROM
Teams,
Channels,
Users,
Posts
LEFT JOIN
Bots ON Bots.UserId = Posts.UserId
WHERE
Teams.Id = Channels.TeamId
AND Posts.ChannelId = Channels.Id
AND Posts.UserId = Users.Id
AND (
Posts.CreateAt > ?
OR (Posts.CreateAt = ? AND Posts.Id > ?)
)
AND Posts.CreateAt < ?
` + emailQuery + `
` + keywordQuery + `
ORDER BY Posts.CreateAt, Posts.Id
LIMIT ?`
if err := s.GetReplica().Select(&channelPosts, channelsQuery, argsChannelsQuery...); err != nil {
return nil, cursor, errors.Wrap(err, "unable to export compliance")
}
if len(channelPosts) < limit {
cursor.ChannelsQueryCompleted = true
} else {
cursor.LastChannelsQueryPostCreateAt = channelPosts[len(channelPosts)-1].PostCreateAt
cursor.LastChannelsQueryPostID = channelPosts[len(channelPosts)-1].PostId
}
}
directMessagePosts := []*model.CompliancePost{}
directMessagesQuery := ""
var argsDirectMessagesQuery []any
if !cursor.DirectMessagesQueryCompleted && len(channelPosts) < limit {
if cursor.LastDirectMessagesQueryPostCreateAt == 0 {
cursor.LastDirectMessagesQueryPostCreateAt = job.StartAt
}
// append the named parameters of SQL query in the correct order to argsDirectMessagesQuery
argsDirectMessagesQuery = append(argsDirectMessagesQuery, cursor.LastDirectMessagesQueryPostCreateAt, cursor.LastDirectMessagesQueryPostCreateAt, cursor.LastDirectMessagesQueryPostID, job.EndAt)
argsDirectMessagesQuery = append(argsDirectMessagesQuery, argsEmails...)
argsDirectMessagesQuery = append(argsDirectMessagesQuery, argsKeywords...)
argsDirectMessagesQuery = append(argsDirectMessagesQuery, limit-len(channelPosts))
directMessagesQuery = `
SELECT
'direct-messages' AS TeamName,
'Direct Messages' AS TeamDisplayName,
Channels.Name AS ChannelName,
Channels.DisplayName AS ChannelDisplayName,
Channels.Type AS ChannelType,
Users.Username AS UserUsername,
Users.Email AS UserEmail,
Users.Nickname AS UserNickname,
Posts.Id AS PostId,
Posts.CreateAt AS PostCreateAt,
Posts.UpdateAt AS PostUpdateAt,
Posts.DeleteAt AS PostDeleteAt,
Posts.RootId AS PostRootId,
Posts.OriginalId AS PostOriginalId,
Posts.Message AS PostMessage,
Posts.Type AS PostType,
Posts.Props AS PostProps,
Posts.Hashtags AS PostHashtags,
Posts.FileIds AS PostFileIds,
Bots.UserId IS NOT NULL AS IsBot
FROM
Channels,
Users,
Posts
LEFT JOIN
Bots ON Bots.UserId = Posts.UserId
WHERE
Channels.TeamId = ''
AND Posts.ChannelId = Channels.Id
AND Posts.UserId = Users.Id
AND (
Posts.CreateAt > ?
OR (Posts.CreateAt = ? AND Posts.Id > ?)
)
AND Posts.CreateAt < ?
` + emailQuery + `
` + keywordQuery + `
ORDER BY Posts.CreateAt, Posts.Id
LIMIT ?`
if err := s.GetReplica().Select(&directMessagePosts, directMessagesQuery, argsDirectMessagesQuery...); err != nil {
return nil, cursor, errors.Wrap(err, "unable to export compliance")
}
if len(directMessagePosts) < limit {
cursor.DirectMessagesQueryCompleted = true
} else {
cursor.LastDirectMessagesQueryPostCreateAt = directMessagePosts[len(directMessagePosts)-1].PostCreateAt
cursor.LastDirectMessagesQueryPostID = directMessagePosts[len(directMessagePosts)-1].PostId
}
}
return append(channelPosts, directMessagePosts...), cursor, nil
}
func (s SqlComplianceStore) MessageExport(rctx request.CTX, cursor model.MessageExportCursor, limit int) ([]*model.MessageExport, model.MessageExportCursor, error) {
caseStmt, caseArgs, caseErr := sq.Case().
When(
sq.Eq{"Channels.Type": model.ChannelTypeDirect},
"'Direct Message'",
).
When(
sq.Eq{"Channels.Type": model.ChannelTypeGroup},
"'Group Message'",
).
Else("Channels.DisplayName").ToSql()
if caseErr != nil {
return nil, cursor, errors.Wrap(caseErr, "unable to construct case statement")
}
builder := s.getQueryBuilder().Select(`Posts.Id AS PostId,
Posts.CreateAt AS PostCreateAt,
Posts.UpdateAt AS PostUpdateAt,
Posts.DeleteAt AS PostDeleteAt,
Posts.EditAt AS PostEditAt,
Posts.Message AS PostMessage,
Posts.Type AS PostType,
Posts.Props AS PostProps,
Posts.OriginalId AS PostOriginalId,
Posts.RootId AS PostRootId,
Posts.FileIds AS PostFileIds,
Teams.Id AS TeamId,
Teams.Name AS TeamName,
Teams.DisplayName AS TeamDisplayName,
Channels.Id AS ChannelId,
Channels.Name AS ChannelName,
Channels.Type AS ChannelType,
Users.Id AS UserId,
Users.Email AS UserEmail,
Users.Username,
Bots.UserId IS NOT NULL AS IsBot`).
Column(caseStmt+" AS ChannelDisplayName", caseArgs...).
From("Posts").
JoinClause("LEFT OUTER JOIN Channels ON Posts.ChannelId = Channels.Id").
JoinClause("LEFT OUTER JOIN Teams ON Channels.TeamId = Teams.Id").
JoinClause("LEFT OUTER JOIN Users ON Posts.UserId = Users.Id").
LeftJoin("Bots ON Bots.UserId = Posts.UserId").
Where(sq.And{
sq.Or{
sq.Gt{"Posts.UpdateAt": cursor.LastPostUpdateAt},
sq.And{
sq.Eq{"Posts.UpdateAt": cursor.LastPostUpdateAt},
sq.Gt{"Posts.Id": cursor.LastPostId},
},
},
sq.NotLike{"Posts.Type": "system_%"},
}).
OrderBy("PostUpdateAt, PostId").
Limit(uint64(limit))
if cursor.UntilUpdateAt > 0 {
builder = builder.Where(sq.LtOrEq{"Posts.UpdateAt": cursor.UntilUpdateAt})
}
cposts := []*model.MessageExport{}
if err := s.GetReplica().SelectBuilderCtx(rctx.Context(), &cposts, builder); err != nil {
return nil, cursor, errors.Wrap(err, "unable to export messages")
}
if len(cposts) > 0 {
cursor.LastPostUpdateAt = *cposts[len(cposts)-1].PostUpdateAt
cursor.LastPostId = *cposts[len(cposts)-1].PostId
}
return cposts, cursor, nil
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestComplianceStore(t *testing.T) {
StoreTest(t, storetest.TestComplianceStore)
}

View File

@@ -0,0 +1,226 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/pkg/errors"
)
type SqlContentFlaggingStore struct {
*SqlStore
}
func newContentFlaggingStore(sqlStore *SqlStore) *SqlContentFlaggingStore {
return &SqlContentFlaggingStore{SqlStore: sqlStore}
}
func (s *SqlContentFlaggingStore) SaveReviewerSettings(reviewerSettings model.ReviewerIDsSettings) error {
tx, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "SqlContentFlaggingStore.SaveReviewerSettings failed to begin transaction")
}
defer finalizeTransactionX(tx, &err)
if err := s.saveCommonReviewers(tx, reviewerSettings.CommonReviewerIds); err != nil {
return err
}
if err := s.saveTeamSettings(tx, reviewerSettings.TeamReviewersSetting); err != nil {
return err
}
if err := s.saveTeamReviewers(tx, reviewerSettings.TeamReviewersSetting); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return errors.Wrap(err, "SqlContentFlaggingStore.SaveReviewerSettings failed to commit transaction")
}
return nil
}
func (s *SqlContentFlaggingStore) saveCommonReviewers(tx *sqlxTxWrapper, commonReviewers []string) error {
// first delete existing common reviewers
deleteBuilder := s.getQueryBuilder().Delete("ContentFlaggingCommonReviewers")
if _, err := tx.ExecBuilder(deleteBuilder); err != nil {
return errors.Wrap(err, "SqlContentFlaggingStore.saveCommonReviewers failed to delete existing common reviewers")
}
if len(commonReviewers) == 0 {
return nil
}
// then insert new common reviewers
insertBuilder := s.getQueryBuilder().
Insert("ContentFlaggingCommonReviewers").
Columns("userid")
for _, userID := range commonReviewers {
insertBuilder = insertBuilder.Values(userID)
}
if _, err := tx.ExecBuilder(insertBuilder); err != nil {
return errors.Wrap(err, "SqlContentFlaggingStore.saveCommonReviewers failed to insert new common reviewers")
}
return nil
}
func (s *SqlContentFlaggingStore) saveTeamSettings(tx *sqlxTxWrapper, teamSettings map[string]*model.TeamReviewerSetting) error {
// first delete existing team settings
deleteBuilder := s.getQueryBuilder().Delete("ContentFlaggingTeamSettings")
if _, err := tx.ExecBuilder(deleteBuilder); err != nil {
return errors.Wrap(err, "SqlContentFlaggingStore.saveTeamSettings failed to delete existing team settings")
}
if len(teamSettings) == 0 {
return nil
}
// then insert new team settings
insertBuilder := s.getQueryBuilder().
Insert("ContentFlaggingTeamSettings").
Columns("teamid", "enabled")
for teamID, teamSetting := range teamSettings {
insertBuilder = insertBuilder.Values(teamID, *teamSetting.Enabled)
}
if _, err := tx.ExecBuilder(insertBuilder); err != nil {
return errors.Wrap(err, "SqlContentFlaggingStore.saveTeamSettings failed to insert new team settings")
}
return nil
}
func (s *SqlContentFlaggingStore) saveTeamReviewers(tx *sqlxTxWrapper, teamSettings map[string]*model.TeamReviewerSetting) error {
// first delete existing team reviewers
deleteBuilder := s.getQueryBuilder().Delete("ContentFlaggingTeamReviewers")
if _, err := tx.ExecBuilder(deleteBuilder); err != nil {
return errors.Wrap(err, "SqlContentFlaggingStore.saveTeamReviewers failed to delete existing team reviewers")
}
if len(teamSettings) == 0 {
return nil
}
// then insert new team reviewers
insertBuilder := s.getQueryBuilder().
Insert("ContentFlaggingTeamReviewers").
Columns("teamid", "userid")
dataExists := false
for teamID, teamSetting := range teamSettings {
if len(teamSetting.ReviewerIds) == 0 {
continue
}
for _, userID := range teamSetting.ReviewerIds {
insertBuilder = insertBuilder.Values(teamID, userID)
}
dataExists = true
}
if dataExists {
if _, err := tx.ExecBuilder(insertBuilder); err != nil {
return errors.Wrap(err, "SqlContentFlaggingStore.saveTeamReviewers failed to insert new team reviewers")
}
}
return nil
}
func (s *SqlContentFlaggingStore) GetReviewerSettings() (*model.ReviewerIDsSettings, error) {
commonReviewers, err := s.getCommonReviewers()
if err != nil {
return nil, errors.Wrap(err, "SqlContentFlaggingStore.GetReviewerSettings failed to get common reviewers")
}
teamSettings := make(map[string]*model.TeamReviewerSetting)
teamSettings, err = s.getTeamSettings(teamSettings)
if err != nil {
return nil, errors.Wrap(err, "SqlContentFlaggingStore.GetReviewerSettings failed to get team settings")
}
teamSettings, err = s.getTeamReviewers(teamSettings)
if err != nil {
return nil, errors.Wrap(err, "SqlContentFlaggingStore.GetReviewerSettings failed to get team reviewers")
}
return &model.ReviewerIDsSettings{
CommonReviewerIds: commonReviewers,
TeamReviewersSetting: teamSettings,
}, nil
}
func (s *SqlContentFlaggingStore) getCommonReviewers() ([]string, error) {
queryBuilder := s.getQueryBuilder().
Select("userid").
From("ContentFlaggingCommonReviewers")
var commonReviewers []string
if err := s.GetReplica().SelectBuilder(&commonReviewers, queryBuilder); err != nil {
return nil, errors.Wrap(err, "SqlContentFlaggingStore.getCommonReviewers failed to get common reviewers")
}
return commonReviewers, nil
}
func (s *SqlContentFlaggingStore) getTeamSettings(teamSettings map[string]*model.TeamReviewerSetting) (map[string]*model.TeamReviewerSetting, error) {
queryBuilder := s.getQueryBuilder().
Select("teamid", "enabled").
From("ContentFlaggingTeamSettings")
var teamSettingsTemp []struct {
TeamID string
Enabled bool
}
if err := s.GetReplica().SelectBuilder(&teamSettingsTemp, queryBuilder); err != nil {
return nil, errors.Wrap(err, "SqlContentFlaggingStore.getTeamSettings failed to get team settings")
}
for _, setting := range teamSettingsTemp {
enabled := setting.Enabled
teamSettings[setting.TeamID] = &model.TeamReviewerSetting{
Enabled: &enabled,
ReviewerIds: []string{},
}
}
return teamSettings, nil
}
func (s *SqlContentFlaggingStore) getTeamReviewers(teamSettings map[string]*model.TeamReviewerSetting) (map[string]*model.TeamReviewerSetting, error) {
queryBuilder := s.getQueryBuilder().
Select("teamid", "userid").
From("ContentFlaggingTeamReviewers")
var teamReviewers []struct {
TeamID string
UserID string
}
if err := s.GetReplica().SelectBuilder(&teamReviewers, queryBuilder); err != nil {
return nil, errors.Wrap(err, "SqlContentFlaggingStore.getTeamReviewers failed to get team reviewers")
}
for _, tr := range teamReviewers {
if _, ok := teamSettings[tr.TeamID]; !ok {
teamSettings[tr.TeamID] = &model.TeamReviewerSetting{
Enabled: nil,
ReviewerIds: []string{},
}
}
teamSettings[tr.TeamID].ReviewerIds = append(teamSettings[tr.TeamID].ReviewerIds, tr.UserID)
}
return teamSettings, nil
}
func (s *SqlContentFlaggingStore) ClearCaches() {}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestContentFlaggingStore(t *testing.T) {
StoreTestWithSqlStore(t, storetest.TestContentFlaggingStore)
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"context"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
// RequestContextWithMaster adds the context value that master DB should be selected for this request.
func RequestContextWithMaster(rctx request.CTX) request.CTX {
return store.RequestContextWithMaster(rctx)
}
// HasMaster is a helper function to check whether master DB should be selected or not.
func HasMaster(ctx context.Context) bool {
return store.HasMaster(ctx)
}
// DBXFromContext is a helper utility that returns the sqlx DB handle from a given context.
func (ss *SqlStore) DBXFromContext(ctx context.Context) *sqlxDBWrapper {
if HasMaster(ctx) {
return ss.GetMaster()
}
return ss.GetReplica()
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/stretchr/testify/assert"
)
func TestRequestContextWithMaster(t *testing.T) {
if enableFullyParallelTests {
t.Parallel()
}
t.Run("set and get", func(t *testing.T) {
var rctx request.CTX = request.TestContext(t)
rctx = RequestContextWithMaster(rctx)
assert.True(t, HasMaster(rctx.Context()))
})
t.Run("values get copied from original context", func(t *testing.T) {
var rctx request.CTX = request.TestContext(t)
rctx = RequestContextWithMaster(rctx)
rctxCopy := rctx
assert.True(t, HasMaster(rctx.Context()))
assert.True(t, HasMaster(rctxCopy.Context()))
})
t.Run("directly assigning does not cause the copy to alter the original context", func(t *testing.T) {
var rctx request.CTX = request.TestContext(t)
rctxCopy := rctx
rctxCopy = RequestContextWithMaster(rctxCopy)
assert.False(t, HasMaster(rctx.Context()))
assert.True(t, HasMaster(rctxCopy.Context()))
})
t.Run("directly assigning does not cause the original context to alter the copy", func(t *testing.T) {
var rctx request.CTX = request.TestContext(t)
rctxCopy := rctx
rctx = RequestContextWithMaster(rctx)
assert.True(t, HasMaster(rctx.Context()))
assert.False(t, HasMaster(rctxCopy.Context()))
})
}

View File

@@ -0,0 +1,110 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
stderrors "errors"
"slices"
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/public/utils"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
type SqlDeliveryTrackingStore struct {
*SqlStore
}
func newSqlDeliveryTrackingStore(sqlStore *SqlStore) store.DeliveryTrackingStore {
return &SqlDeliveryTrackingStore{SqlStore: sqlStore}
}
func (s *SqlDeliveryTrackingStore) SaveTrackedChannelIDs(rctx request.CTX, channelIDs []string) error {
tx, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "SqlDeliveryTrackingStore.SaveTrackedChannelIDs failed to begin transaction")
}
defer finalizeTransactionX(tx, &err)
deleteBuilder := s.getQueryBuilder().Delete("PostDeliveryTrackingChannels")
if _, err := tx.ExecBuilder(deleteBuilder); err != nil {
return errors.Wrap(err, "SqlDeliveryTrackingStore.SaveTrackedChannelIDs failed to delete existing tracked channels")
}
// Dedup so the unique constraint isn't violated, and drop empty ids.
uniqueChannelIDs := slices.DeleteFunc(utils.Dedup(channelIDs), func(channelID string) bool {
return channelID == ""
})
if len(uniqueChannelIDs) > 0 {
insertBuilder := s.getQueryBuilder().
Insert("PostDeliveryTrackingChannels").
Columns("ChannelId")
for _, channelID := range uniqueChannelIDs {
insertBuilder = insertBuilder.Values(channelID)
}
if _, err := tx.ExecBuilder(insertBuilder); err != nil {
return errors.Wrap(err, "SqlDeliveryTrackingStore.SaveTrackedChannelIDs failed to insert new tracked channels")
}
}
if err := tx.Commit(); err != nil {
return errors.Wrap(err, "SqlDeliveryTrackingStore.SaveTrackedChannelIDs failed to commit transaction")
}
return nil
}
// ClearCaches is a no-op; only the local cache layer holds a cache.
func (s *SqlDeliveryTrackingStore) ClearCaches() {
}
func (s *SqlDeliveryTrackingStore) GetTrackedChannelIDs(rctx request.CTX) ([]string, error) {
query := s.getQueryBuilder().
Select("ChannelId").
From("PostDeliveryTrackingChannels")
channelIDs := []string{}
if err := s.DBXFromContext(rctx.Context()).SelectBuilder(&channelIDs, query); err != nil {
return nil, errors.Wrap(err, "SqlDeliveryTrackingStore.GetTrackedChannelIDs failed to select tracked channels")
}
return channelIDs, nil
}
func (s *SqlDeliveryTrackingStore) IsChannelTracked(rctx request.CTX, channelID string) (bool, error) {
query := s.getQueryBuilder().
Select("1").
From("PostDeliveryTrackingChannels").
Where(sq.Eq{"ChannelId": channelID}).
Limit(1)
var exists []int
if err := s.DBXFromContext(rctx.Context()).SelectBuilder(&exists, query); err != nil {
return false, errors.Wrapf(err, "SqlDeliveryTrackingStore.IsChannelTracked failed for channel_id=%s", channelID)
}
return len(exists) > 0, nil
}
// IsChannelTrackable reads through the channel store rather than querying Channels directly, so
// that the cache layer's override resolves it from the channel cache. Unknown channels, including
// types that do not carry posts, are not trackable.
func (s *SqlDeliveryTrackingStore) IsChannelTrackable(rctx request.CTX, channelID string) (bool, error) {
channel, err := s.SqlStore.Channel().Get(channelID, true)
if err != nil {
var nfErr *store.ErrNotFound
if !stderrors.As(err, &nfErr) {
return false, errors.Wrapf(err, "SqlDeliveryTrackingStore.IsChannelTrackable failed for channel_id=%s", channelID)
}
return false, nil
}
return !channel.IsGroupOrDirect(), nil
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestDeliveryTrackingStore(t *testing.T) {
StoreTest(t, storetest.TestDeliveryTrackingStore)
}

View File

@@ -0,0 +1,123 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/einterfaces"
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
)
type SqlDesktopTokensStore struct {
*SqlStore
metrics einterfaces.MetricsInterface
}
func newSqlDesktopTokensStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterface) store.DesktopTokensStore {
return &SqlDesktopTokensStore{
SqlStore: sqlStore,
metrics: metrics,
}
}
func (s *SqlDesktopTokensStore) GetUserId(token string, minCreateAt int64) (*string, error) {
query := s.getQueryBuilder().
Select("UserId").
From("DesktopTokens").
Where(sq.And{
sq.Eq{"Token": token},
sq.GtOrEq{"CreateAt": minCreateAt},
})
dt := struct{ UserId string }{}
err := s.GetReplica().GetBuilder(&dt, query)
if err != nil {
if err == sql.ErrNoRows {
return nil, store.NewErrNotFound("DesktopTokens", token)
}
return nil, errors.Wrapf(err, "No token for %s", token)
}
return &dt.UserId, nil
}
func (s *SqlDesktopTokensStore) Insert(token string, createAt int64, userId string) error {
builder := s.getQueryBuilder().
Insert("DesktopTokens").
Columns("Token", "CreateAt", "UserId").
Values(token, createAt, userId)
query, args, err := builder.ToSql()
if err != nil {
return errors.Wrap(err, "insert_desktoptokens_tosql")
}
if _, err = s.GetMaster().Exec(query, args...); err != nil {
return errors.Wrap(err, "failed to insert token row")
}
return nil
}
func (s *SqlDesktopTokensStore) Delete(token string) error {
builder := s.getQueryBuilder().
Delete("DesktopTokens").
Where(sq.Eq{
"Token": token,
})
query, args, err := builder.ToSql()
if err != nil {
return errors.Wrap(err, "delete_desktoptokens_tosql")
}
if _, err = s.GetMaster().Exec(query, args...); err != nil {
return errors.Wrap(err, "failed to delete token row")
}
return nil
}
func (s *SqlDesktopTokensStore) DeleteByUserId(userId string) error {
builder := s.getQueryBuilder().
Delete("DesktopTokens").
Where(sq.Eq{
"UserId": userId,
})
query, args, err := builder.ToSql()
if err != nil {
return errors.Wrap(err, "delete_by_userid_desktoptokens_tosql")
}
if _, err = s.GetMaster().Exec(query, args...); err != nil {
return errors.Wrap(err, "failed to delete token row")
}
return nil
}
func (s *SqlDesktopTokensStore) DeleteOlderThan(minCreateAt int64) error {
builder := s.getQueryBuilder().
Delete("DesktopTokens").
Where(sq.Lt{
"CreateAt": minCreateAt,
})
query, args, err := builder.ToSql()
if err != nil {
return errors.Wrap(err, "delete_old_desktoptokens_tosql")
}
if _, err = s.GetMaster().Exec(query, args...); err != nil {
return errors.Wrap(err, "failed to delete token row")
}
return nil
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestDesktopTokensStore(t *testing.T) {
StoreTestWithSqlStore(t, storetest.TestDesktopTokensStore)
}

View File

@@ -0,0 +1,178 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"context"
"database/sql"
"time"
"github.com/hashicorp/go-multierror"
"github.com/jmoiron/sqlx"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
const pgDiagnosticsQueryTimeout = 10 * time.Second
func (ss *SqlStore) GetDiagnostics(rctx request.CTX) (*store.DatabaseDiagnostics, error) {
diagnostics := &store.DatabaseDiagnostics{}
applyDBPoolStats(diagnostics, ss.MasterDBStats(), ss.ReplicaDBStats())
if ss.DriverName() != model.DatabaseDriverPostgres {
return diagnostics, nil
}
if err := collectPostgresDatabaseDiagnostics(rctx.Context(), ss.GetMaster().DB(), diagnostics); err != nil {
return diagnostics, err
}
return diagnostics, nil
}
func collectPostgresDatabaseDiagnostics(ctx context.Context, db *sqlx.DB, diagnostics *store.DatabaseDiagnostics) error {
if db == nil {
return errors.New("postgres diagnostics query failed: no master database connection")
}
var rErr *multierror.Error
if err := withDiagnosticsTimeout(ctx, func(ctx context.Context) error {
return collectPGStatDatabaseDiagnostics(ctx, db, diagnostics)
}); err != nil {
rErr = multierror.Append(rErr, errors.Wrap(err, "postgres diagnostics query failed for pg_stat_database"))
}
if err := withDiagnosticsTimeout(ctx, func(ctx context.Context) error {
return collectPGStatActivityDiagnostics(ctx, db, diagnostics)
}); err != nil {
rErr = multierror.Append(rErr, errors.Wrap(err, "postgres diagnostics query failed for pg_stat_activity"))
}
if err := withDiagnosticsTimeout(ctx, func(ctx context.Context) error {
return collectPGStatUserTablesDiagnostics(ctx, db, diagnostics)
}); err != nil {
rErr = multierror.Append(rErr, errors.Wrap(err, "postgres diagnostics query failed for pg_stat_user_tables"))
}
return rErr.ErrorOrNil()
}
func withDiagnosticsTimeout(ctx context.Context, fn func(context.Context) error) error {
ctx, cancel := context.WithTimeout(ctx, pgDiagnosticsQueryTimeout)
defer cancel()
return fn(ctx)
}
func applyDBPoolStats(diagnostics *store.DatabaseDiagnostics, masterDBStats, replicaDBStats sql.DBStats) {
diagnostics.MasterConnectionsInUse = masterDBStats.InUse
diagnostics.MasterConnectionsIdle = masterDBStats.Idle
diagnostics.MasterPoolWaitCount = masterDBStats.WaitCount
diagnostics.MasterPoolWaitDurationMs = masterDBStats.WaitDuration.Milliseconds()
diagnostics.MasterConnectionsClosedMaxIdle = masterDBStats.MaxIdleClosed
diagnostics.MasterConnectionsClosedMaxLifetime = masterDBStats.MaxLifetimeClosed
diagnostics.ReplicaConnectionsInUse = replicaDBStats.InUse
diagnostics.ReplicaConnectionsIdle = replicaDBStats.Idle
diagnostics.ReplicaPoolWaitCount = replicaDBStats.WaitCount
diagnostics.ReplicaPoolWaitDurationMs = replicaDBStats.WaitDuration.Milliseconds()
diagnostics.ReplicaConnectionsClosedMaxIdle = replicaDBStats.MaxIdleClosed
diagnostics.ReplicaConnectionsClosedMaxLifetime = replicaDBStats.MaxLifetimeClosed
}
func collectPGStatDatabaseDiagnostics(ctx context.Context, db *sqlx.DB, diagnostics *store.DatabaseDiagnostics) error {
var row struct {
CacheHitRatio float64 `db:"cache_hit_ratio"`
Deadlocks int64 `db:"deadlocks"`
TempFiles int64 `db:"temp_files"`
TempBytes int64 `db:"temp_bytes"`
XactRollback int64 `db:"xact_rollback"`
}
const query = `
SELECT
COALESCE(blks_hit::double precision / NULLIF(blks_hit + blks_read, 0), 0) AS cache_hit_ratio,
deadlocks,
temp_files,
temp_bytes,
xact_rollback
FROM pg_stat_database
WHERE datname = current_database()`
if err := db.GetContext(ctx, &row, query); err != nil {
return err
}
tempBytesMB := float64(row.TempBytes) / (1024 * 1024)
diagnostics.CacheHitRatio = &row.CacheHitRatio
diagnostics.Deadlocks = &row.Deadlocks
diagnostics.TempFiles = &row.TempFiles
diagnostics.TempBytesMB = &tempBytesMB
diagnostics.Rollbacks = &row.XactRollback
return nil
}
func collectPGStatActivityDiagnostics(ctx context.Context, db *sqlx.DB, diagnostics *store.DatabaseDiagnostics) error {
var row struct {
IdleInTransactionCount int64 `db:"idle_in_transaction_count"`
LongestQueryDurationSeconds float64 `db:"longest_query_duration_seconds"`
WaitingForLockCount int64 `db:"waiting_for_lock_count"`
}
const query = `
SELECT
COUNT(*) FILTER (WHERE state = 'idle in transaction') AS idle_in_transaction_count,
EXTRACT(EPOCH FROM COALESCE(
MAX(clock_timestamp() - query_start) FILTER (WHERE state = 'active' AND query_start IS NOT NULL),
interval '0 second'
)) AS longest_query_duration_seconds,
COUNT(*) FILTER (WHERE wait_event_type = 'Lock') AS waiting_for_lock_count
FROM pg_stat_activity
WHERE datname = current_database()`
if err := db.GetContext(ctx, &row, query); err != nil {
return err
}
diagnostics.IdleInTransactionCount = &row.IdleInTransactionCount
diagnostics.LongestQueryDurationSeconds = &row.LongestQueryDurationSeconds
diagnostics.WaitingForLockCount = &row.WaitingForLockCount
return nil
}
func collectPGStatUserTablesDiagnostics(ctx context.Context, db *sqlx.DB, diagnostics *store.DatabaseDiagnostics) error {
var row struct {
NDeadTup int64 `db:"n_dead_tup"`
LastAutovacuum sql.NullTime `db:"last_autovacuum"`
}
const query = `
SELECT
n_dead_tup,
last_autovacuum
FROM pg_stat_user_tables
WHERE lower(relname) = 'posts'
AND schemaname = current_schema()
LIMIT 1`
if err := db.GetContext(ctx, &row, query); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil
}
return err
}
diagnostics.PostsDeadTuples = &row.NDeadTup
if row.LastAutovacuum.Valid {
ts := row.LastAutovacuum.Time.UTC()
diagnostics.PostsLastAutovacuum = &ts
}
return nil
}

View File

@@ -0,0 +1,83 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
func TestGetDiagnostics(t *testing.T) {
StoreTest(t, func(t *testing.T, rctx request.CTX, ss store.Store) {
sqlStore := ss.(*SqlStore)
diagnostics, err := sqlStore.GetDiagnostics(rctx)
require.NoError(t, err)
require.NotNil(t, diagnostics)
// Pool stats are populated for every supported driver.
assert.GreaterOrEqual(t, diagnostics.MasterConnectionsInUse, 0)
assert.GreaterOrEqual(t, diagnostics.MasterConnectionsIdle, 0)
if sqlStore.DriverName() != model.DatabaseDriverPostgres {
assert.Nil(t, diagnostics.CacheHitRatio)
return
}
require.NotNil(t, diagnostics.CacheHitRatio)
assert.GreaterOrEqual(t, *diagnostics.CacheHitRatio, 0.0)
assert.LessOrEqual(t, *diagnostics.CacheHitRatio, 1.0)
require.NotNil(t, diagnostics.Deadlocks)
require.NotNil(t, diagnostics.TempFiles)
require.NotNil(t, diagnostics.TempBytesMB)
require.NotNil(t, diagnostics.Rollbacks)
require.NotNil(t, diagnostics.IdleInTransactionCount)
require.NotNil(t, diagnostics.LongestQueryDurationSeconds)
require.NotNil(t, diagnostics.WaitingForLockCount)
})
}
func TestApplyDBPoolStats(t *testing.T) {
diagnostics := &store.DatabaseDiagnostics{}
applyDBPoolStats(
diagnostics,
sql.DBStats{
InUse: 3,
Idle: 7,
WaitCount: 11,
WaitDuration: 2*time.Second + 25*time.Millisecond,
MaxIdleClosed: 13,
MaxLifetimeClosed: 17,
},
sql.DBStats{
InUse: 5,
Idle: 9,
WaitCount: 19,
WaitDuration: 4*time.Second + 90*time.Millisecond,
MaxIdleClosed: 23,
MaxLifetimeClosed: 29,
},
)
assert.Equal(t, 3, diagnostics.MasterConnectionsInUse)
assert.Equal(t, 7, diagnostics.MasterConnectionsIdle)
assert.Equal(t, int64(11), diagnostics.MasterPoolWaitCount)
assert.Equal(t, int64(2025), diagnostics.MasterPoolWaitDurationMs)
assert.Equal(t, int64(13), diagnostics.MasterConnectionsClosedMaxIdle)
assert.Equal(t, int64(17), diagnostics.MasterConnectionsClosedMaxLifetime)
assert.Equal(t, 5, diagnostics.ReplicaConnectionsInUse)
assert.Equal(t, 9, diagnostics.ReplicaConnectionsIdle)
assert.Equal(t, int64(19), diagnostics.ReplicaPoolWaitCount)
assert.Equal(t, int64(4090), diagnostics.ReplicaPoolWaitDurationMs)
assert.Equal(t, int64(23), diagnostics.ReplicaConnectionsClosedMaxIdle)
assert.Equal(t, int64(29), diagnostics.ReplicaConnectionsClosedMaxLifetime)
}

View File

@@ -0,0 +1,353 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"sync"
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/einterfaces"
)
type SqlDraftStore struct {
*SqlStore
metrics einterfaces.MetricsInterface
maxDraftSizeOnce sync.Once
maxDraftSizeCached int
}
func draftSliceColumns() []string {
return []string{
"CreateAt",
"UpdateAt",
"DeleteAt",
"Message",
"RootId",
"ChannelId",
"UserId",
"FileIds",
"Props",
"Priority",
"Type",
}
}
func draftSelectColumns() []string {
cols := make([]string, len(draftSliceColumns()))
copy(cols, draftSliceColumns())
cols[len(cols)-1] = "COALESCE(Type, '') AS Type"
return cols
}
func draftToSlice(draft *model.Draft) []any {
return []any{
draft.CreateAt,
draft.UpdateAt,
draft.DeleteAt,
draft.Message,
draft.RootId,
draft.ChannelId,
draft.UserId,
model.ArrayToJSON(draft.FileIds),
model.StringInterfaceToJSON(draft.Props),
model.StringInterfaceToJSON(draft.Priority),
draft.Type,
}
}
func newSqlDraftStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterface) store.DraftStore {
return &SqlDraftStore{
SqlStore: sqlStore,
metrics: metrics,
maxDraftSizeCached: model.PostMessageMaxRunesV1,
}
}
func (s *SqlDraftStore) Get(userId, channelId, rootId string, includeDeleted bool) (*model.Draft, error) {
query := s.getQueryBuilder().
Select(draftSelectColumns()...).
From("Drafts").
Where(sq.Eq{
"UserId": userId,
"ChannelId": channelId,
"RootId": rootId,
})
if !includeDeleted {
query = query.Where(sq.Eq{"DeleteAt": 0})
}
dt := model.Draft{}
err := s.GetReplica().GetBuilder(&dt, query)
if err != nil {
if err == sql.ErrNoRows {
return nil, store.NewErrNotFound("Draft", channelId)
}
return nil, errors.Wrapf(err, "failed to find draft with channelid = %s", channelId)
}
return &dt, nil
}
func (s *SqlDraftStore) Upsert(draft *model.Draft) (*model.Draft, error) {
draft.PreSave()
maxDraftSize := s.GetMaxDraftSize()
if err := draft.IsValid(maxDraftSize); err != nil {
return nil, err
}
builder := s.getQueryBuilder().Insert("Drafts").
Columns(draftSliceColumns()...).
Values(draftToSlice(draft)...).
SuffixExpr(sq.Expr("ON CONFLICT (UserId, ChannelId, RootId) DO UPDATE SET UpdateAt = ?, Message = ?, Props = ?, FileIds = ?, Priority = ?, Type = ?, DeleteAt = ?", draft.UpdateAt, draft.Message, draft.Props, draft.FileIds, draft.Priority, draft.Type, 0))
query, args, err := builder.ToSql()
if err != nil {
return nil, errors.Wrap(err, "save_draft_tosql")
}
if _, err = s.GetMaster().Exec(query, args...); err != nil {
return nil, errors.Wrap(err, "failed to upsert Draft")
}
return draft, nil
}
func (s *SqlDraftStore) GetDraftsForUser(userID, teamID string) ([]*model.Draft, error) {
var drafts []*model.Draft
query := s.getQueryBuilder().
Select(
"Drafts.CreateAt",
"Drafts.UpdateAt",
"Drafts.Message",
"Drafts.RootId",
"Drafts.ChannelId",
"Drafts.UserId",
"Drafts.FileIds",
"Drafts.Props",
"Drafts.Priority",
"COALESCE(Drafts.Type, '') AS Type",
).
From("Drafts").
InnerJoin("ChannelMembers ON ChannelMembers.ChannelId = Drafts.ChannelId").
Where(sq.And{
sq.Eq{"Drafts.DeleteAt": 0},
sq.Eq{"Drafts.UserId": userID},
sq.Eq{"ChannelMembers.UserId": userID},
}).
OrderBy("Drafts.UpdateAt DESC")
if teamID != "" {
query = query.
Join("Channels ON Drafts.ChannelId = Channels.Id").
Where(sq.Or{
sq.Eq{"Channels.TeamId": teamID},
sq.Eq{"Channels.TeamId": ""},
})
}
err := s.GetReplica().SelectBuilder(&drafts, query)
if err != nil {
return nil, errors.Wrap(err, "failed to get user drafts")
}
return drafts, nil
}
func (s *SqlDraftStore) Delete(userID, channelID, rootID string) error {
query := s.getQueryBuilder().
Delete("Drafts").
Where(sq.Eq{
"UserId": userID,
"ChannelId": channelID,
"RootId": rootID,
})
sql, args, err := query.ToSql()
if err != nil {
return errors.Wrapf(err, "failed to convert to sql")
}
_, err = s.GetMaster().Exec(sql, args...)
if err != nil {
return errors.Wrap(err, "failed to delete Draft")
}
return nil
}
func (s *SqlDraftStore) PermanentDeleteByUser(userID string) error {
query := s.getQueryBuilder().
Delete("Drafts").
Where(sq.Eq{
"UserId": userID,
})
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
return errors.Wrapf(err, "PermanentDeleteByUser: failed to delete drafts for user: %s", userID)
}
return nil
}
// DeleteDraftsAssociatedWithPost deletes all drafts associated with a post.
func (s *SqlDraftStore) DeleteDraftsAssociatedWithPost(channelID, rootID string) error {
query := s.getQueryBuilder().
Delete("Drafts").
Where(sq.Eq{
"ChannelId": channelID,
"RootId": rootID,
})
sql, args, err := query.ToSql()
if err != nil {
return errors.Wrapf(err, "failed to convert to sql")
}
_, err = s.GetMaster().Exec(sql, args...)
if err != nil {
return errors.Wrap(err, "failed to delete Draft")
}
return nil
}
// GetMaxDraftSize returns the maximum number of runes that may be stored in a post.
func (s *SqlDraftStore) GetMaxDraftSize() int {
s.maxDraftSizeOnce.Do(func() {
s.maxDraftSizeCached = s.determineMaxDraftSize()
})
return s.maxDraftSizeCached
}
func (s *SqlDraftStore) determineMaxDraftSize() int {
var maxDraftSizeBytes int32
// The Draft.Message column has historically been VARCHAR(4000), but
// may be manually enlarged to support longer drafts.
if err := s.GetReplica().Get(&maxDraftSizeBytes, `
SELECT
COALESCE(character_maximum_length, 0)
FROM
information_schema.columns
WHERE
table_name = 'drafts'
AND column_name = 'message'
`); err != nil {
mlog.Warn("Unable to determine the maximum supported draft size", mlog.Err(err))
}
// Assume a worst-case representation of four bytes per rune.
maxDraftSize := int(maxDraftSizeBytes) / 4
mlog.Info("Draft.Message has size restrictions", mlog.Int("max_characters", maxDraftSize), mlog.Int("max_bytes", maxDraftSizeBytes))
return maxDraftSize
}
func (s *SqlDraftStore) GetLastCreateAtAndUserIdValuesForEmptyDraftsMigration(createAt int64, userId string) (int64, string, error) {
var drafts []struct {
CreateAt int64
UserId string
}
query := s.getQueryBuilder().
Select("CreateAt", "UserId").
From("Drafts").
Where(sq.Or{
sq.Gt{"CreateAt": createAt},
sq.And{
sq.Eq{"CreateAt": createAt},
sq.Gt{"UserId": userId},
},
}).
OrderBy("CreateAt", "UserId ASC").
Limit(100)
err := s.GetReplica().SelectBuilder(&drafts, query)
if err != nil {
return 0, "", errors.Wrap(err, "failed to get the list of drafts")
}
if len(drafts) == 0 {
return 0, "", nil
}
lastElement := drafts[len(drafts)-1]
return lastElement.CreateAt, lastElement.UserId, nil
}
func (s *SqlDraftStore) DeleteEmptyDraftsByCreateAtAndUserId(createAt int64, userId string) error {
builder := s.getQueryBuilder().
Delete("Drafts d").
PrefixExpr(s.getQueryBuilder().Select().
Prefix("WITH dd AS (").
Columns("UserId", "ChannelId", "RootId").
From("Drafts").
Where(sq.Or{
sq.Gt{"CreateAt": createAt},
sq.And{
sq.Eq{"CreateAt": createAt},
sq.Gt{"UserId": userId},
},
}).
OrderBy("CreateAt", "UserId").
Limit(100).
Suffix(")"),
).
Using("dd").
Where("d.UserId = dd.UserId").
Where("d.ChannelId = dd.ChannelId").
Where("d.RootId = dd.RootId").
Where("d.Message = ''")
if _, err := s.GetMaster().ExecBuilder(builder); err != nil {
return errors.Wrapf(err, "failed to delete empty drafts")
}
return nil
}
func (s *SqlDraftStore) DeleteOrphanDraftsByCreateAtAndUserId(createAt int64, userId string) error {
builder := s.getQueryBuilder().
Delete("Drafts d").
PrefixExpr(s.getQueryBuilder().Select().
Prefix("WITH dd AS (").
Columns("UserId", "ChannelId", "RootId").
From("Drafts").
Where(sq.Or{
sq.Gt{"CreateAt": createAt},
sq.And{
sq.Eq{"CreateAt": createAt},
sq.Gt{"UserId": userId},
},
}).
OrderBy("CreateAt", "UserId").
Limit(100).
Suffix(")"),
).
Using("dd").
Where("d.UserId = dd.UserId").
Where("d.ChannelId = dd.ChannelId").
Where("d.RootId = dd.RootId").
Suffix("AND (d.RootId IN (SELECT Id FROM Posts WHERE DeleteAt <> 0) OR NOT EXISTS (SELECT 1 FROM Posts WHERE Posts.Id = d.RootId))")
if _, err := s.GetMaster().ExecBuilder(builder); err != nil {
return errors.Wrapf(err, "failed to delete orphan drafts")
}
return nil
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestDraftStore(t *testing.T) {
StoreTestWithSqlStore(t, storetest.TestDraftStore)
}

View File

@@ -0,0 +1,145 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"fmt"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/einterfaces"
sq "github.com/mattermost/squirrel"
)
type SqlEmojiStore struct {
*SqlStore
metrics einterfaces.MetricsInterface
emojiSelectQuery sq.SelectBuilder
}
func newSqlEmojiStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterface) store.EmojiStore {
emojiSelectQuery := sqlStore.getQueryBuilder().
Select("Id", "CreateAt", "UpdateAt", "DeleteAt", "CreatorId", "Name").
From("Emoji").
Where(sq.Eq{"DeleteAt": 0})
return &SqlEmojiStore{
SqlStore: sqlStore,
metrics: metrics,
emojiSelectQuery: emojiSelectQuery,
}
}
func (es SqlEmojiStore) Save(emoji *model.Emoji) (*model.Emoji, error) {
emoji.PreSave()
if err := emoji.IsValid(); err != nil {
return nil, err
}
if _, err := es.GetMaster().NamedExec(`INSERT INTO Emoji
(Id, CreateAt, UpdateAt, DeleteAt, CreatorId, Name)
VALUES
(:Id, :CreateAt, :UpdateAt, :DeleteAt, :CreatorId, :Name)`, emoji); err != nil {
return nil, errors.Wrap(err, "error saving emoji")
}
return emoji, nil
}
func (es SqlEmojiStore) Get(rctx request.CTX, id string, allowFromCache bool) (*model.Emoji, error) {
return es.getBy(rctx, "Id", id)
}
func (es SqlEmojiStore) GetByName(rctx request.CTX, name string, allowFromCache bool) (*model.Emoji, error) {
return es.getBy(rctx, "Name", name)
}
func (es SqlEmojiStore) GetMultipleByName(rctx request.CTX, names []string) ([]*model.Emoji, error) {
query := es.emojiSelectQuery.Where(sq.Eq{"Name": names})
emojis := []*model.Emoji{}
if err := es.DBXFromContext(rctx.Context()).SelectBuilder(&emojis, query); err != nil {
return nil, errors.Wrapf(err, "error getting emojis by names %v", names)
}
return emojis, nil
}
func (es SqlEmojiStore) GetList(offset, limit int, sort string) ([]*model.Emoji, error) {
emojis := []*model.Emoji{}
query := es.emojiSelectQuery
if sort == model.EmojiSortByName {
query = query.OrderBy("Name")
}
query = query.Limit(uint64(limit)).Offset(uint64(offset))
if err := es.GetReplica().SelectBuilder(&emojis, query); err != nil {
return nil, errors.Wrap(err, "could not get list of emojis")
}
return emojis, nil
}
func (es SqlEmojiStore) Delete(emoji *model.Emoji, time int64) error {
if sqlResult, err := es.GetMaster().Exec(
`UPDATE
Emoji
SET
DeleteAt = ?,
UpdateAt = ?
WHERE
Id = ?
AND DeleteAt = 0`, time, time, emoji.Id); err != nil {
return errors.Wrap(err, "could not delete emoji")
} else if rows, err := sqlResult.RowsAffected(); rows == 0 {
return store.NewErrNotFound("Emoji", emoji.Id).Wrap(err)
}
return nil
}
func (es SqlEmojiStore) Search(name string, prefixOnly bool, limit int) ([]*model.Emoji, error) {
emojis := []*model.Emoji{}
name = sanitizeSearchTerm(name, "\\")
term := ""
if !prefixOnly {
term = "%"
}
term += name + "%"
query := es.emojiSelectQuery.
Where(sq.Like{"Name": term}).
OrderBy("Name").
Limit(uint64(limit))
if err := es.GetReplica().SelectBuilder(&emojis, query); err != nil {
return nil, errors.Wrapf(err, "could not search emojis by name %s", name)
}
return emojis, nil
}
// getBy returns one active (not deleted) emoji, found by any one column (what/key).
func (es SqlEmojiStore) getBy(rctx request.CTX, what, key string) (*model.Emoji, error) {
var emoji model.Emoji
query := es.emojiSelectQuery.Where(sq.Eq{what: key})
err := es.DBXFromContext(rctx.Context()).GetBuilder(&emoji, query)
if err != nil {
if err == sql.ErrNoRows {
return nil, store.NewErrNotFound("Emoji", fmt.Sprintf("%s=%s", what, key))
}
return nil, errors.Wrapf(err, "could not get emoji by %s with value %s", what, key)
}
return &emoji, nil
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestEmojiStore(t *testing.T) {
StoreTest(t, storetest.TestEmojiStore)
}

View File

@@ -0,0 +1,823 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"fmt"
"regexp"
"strconv"
"strings"
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
"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/einterfaces"
)
type fileInfoWithChannelID struct {
Id string
CreatorId string
PostId string
ChannelId string
CreateAt int64
UpdateAt int64
DeleteAt int64
Path string
ThumbnailPath string
PreviewPath string
Name string
Extension string
Size int64
MimeType string
Width int
Height int
HasPreviewImage bool
MiniPreview *[]byte
Content string
RemoteId *string
Archived bool
}
func (fi fileInfoWithChannelID) ToModel() *model.FileInfo {
return &model.FileInfo{
Id: fi.Id,
CreatorId: fi.CreatorId,
PostId: fi.PostId,
ChannelId: fi.ChannelId,
CreateAt: fi.CreateAt,
UpdateAt: fi.UpdateAt,
DeleteAt: fi.DeleteAt,
Path: fi.Path,
ThumbnailPath: fi.ThumbnailPath,
PreviewPath: fi.PreviewPath,
Name: fi.Name,
Extension: fi.Extension,
Size: fi.Size,
MimeType: fi.MimeType,
Width: fi.Width,
Height: fi.Height,
HasPreviewImage: fi.HasPreviewImage,
MiniPreview: fi.MiniPreview,
Content: fi.Content,
RemoteId: fi.RemoteId,
}
}
type SqlFileInfoStore struct {
*SqlStore
metrics einterfaces.MetricsInterface
queryFields []string
}
func (fs SqlFileInfoStore) ClearCaches() {
}
func newSqlFileInfoStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterface) store.FileInfoStore {
s := &SqlFileInfoStore{
SqlStore: sqlStore,
metrics: metrics,
}
s.queryFields = []string{
"FileInfo.Id",
"FileInfo.CreatorId",
"FileInfo.PostId",
"COALESCE(FileInfo.ChannelId, '') AS ChannelId",
"FileInfo.CreateAt",
"FileInfo.UpdateAt",
"FileInfo.DeleteAt",
"FileInfo.Path",
"FileInfo.ThumbnailPath",
"FileInfo.PreviewPath",
"FileInfo.Name",
"FileInfo.Extension",
"FileInfo.Size",
"FileInfo.MimeType",
"FileInfo.Width",
"FileInfo.Height",
"FileInfo.HasPreviewImage",
"FileInfo.MiniPreview",
"Coalesce(FileInfo.Content, '') AS Content",
"Coalesce(FileInfo.RemoteId, '') AS RemoteId",
"FileInfo.Archived",
}
return s
}
func (fs SqlFileInfoStore) Save(rctx request.CTX, info *model.FileInfo) (*model.FileInfo, error) {
info.PreSave()
if err := info.IsValid(); err != nil {
return nil, err
}
query := `
INSERT INTO FileInfo
(Id, CreatorId, PostId, ChannelId, CreateAt, UpdateAt, DeleteAt, Path, ThumbnailPath, PreviewPath,
Name, Extension, Size, MimeType, Width, Height, HasPreviewImage, MiniPreview, Content, RemoteId)
VALUES
(:Id, :CreatorId, :PostId, :ChannelId, :CreateAt, :UpdateAt, :DeleteAt, :Path, :ThumbnailPath, :PreviewPath,
:Name, :Extension, :Size, :MimeType, :Width, :Height, :HasPreviewImage, :MiniPreview, :Content, :RemoteId)
`
if _, err := fs.GetMaster().NamedExec(query, info); err != nil {
return nil, errors.Wrap(err, "failed to save FileInfo")
}
return info, nil
}
func (fs SqlFileInfoStore) GetByIds(ids []string, includeDeleted, allowFromCache, readFromMaster bool) ([]*model.FileInfo, error) {
db := fs.GetReplica()
if readFromMaster {
db = fs.GetMaster()
}
query := fs.getQueryBuilder().
Select(fs.queryFields...).
From("FileInfo").
Where(sq.Eq{"FileInfo.Id": ids}).
OrderBy("FileInfo.CreateAt DESC")
if !includeDeleted {
query = query.Where(sq.Eq{"FileInfo.DeleteAt": 0})
}
queryString, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrap(err, "file_info_tosql")
}
items := []fileInfoWithChannelID{}
if err := db.Select(&items, queryString, args...); err != nil {
return nil, errors.Wrap(err, "failed to find FileInfos")
}
if len(items) == 0 {
return nil, nil
}
infos := make([]*model.FileInfo, 0, len(items))
for _, item := range items {
infos = append(infos, item.ToModel())
}
return infos, nil
}
func (fs SqlFileInfoStore) Upsert(rctx request.CTX, info *model.FileInfo) (*model.FileInfo, error) {
info.PreSave()
if err := info.IsValid(); err != nil {
return nil, err
}
// PostID and ChannelID are deliberately ignored
// from the list of fields to keep those two immutable.
queryString, args, err := fs.getQueryBuilder().
Update("FileInfo").
SetMap(map[string]any{
"UpdateAt": info.UpdateAt,
"DeleteAt": info.DeleteAt,
"Path": info.Path,
"ThumbnailPath": info.ThumbnailPath,
"PreviewPath": info.PreviewPath,
"Name": info.Name,
"Extension": info.Extension,
"Size": info.Size,
"MimeType": info.MimeType,
"Width": info.Width,
"Height": info.Height,
"HasPreviewImage": info.HasPreviewImage,
"MiniPreview": info.MiniPreview,
"Content": info.Content,
"RemoteId": info.RemoteId,
}).
Where(sq.Eq{"Id": info.Id}).
ToSql()
if err != nil {
return nil, errors.Wrap(err, "file_info_tosql")
}
sqlResult, err := fs.GetMaster().Exec(queryString, args...)
if err != nil {
return nil, errors.Wrap(err, "failed to update FileInfo")
}
count, err := sqlResult.RowsAffected()
if err != nil {
return nil, errors.Wrap(err, "unable to retrieve rows affected")
}
if count == 0 {
return fs.Save(rctx, info)
}
return info, nil
}
func (fs SqlFileInfoStore) get(id string, fromMaster bool) (*model.FileInfo, error) {
info := &model.FileInfo{}
query := fs.getQueryBuilder().
Select(fs.queryFields...).
From("FileInfo").
Where(sq.Eq{"Id": id}).
Where(sq.Eq{"DeleteAt": 0})
queryString, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrap(err, "file_info_tosql")
}
db := fs.GetReplica()
if fromMaster {
db = fs.GetMaster()
}
if err := db.Get(info, queryString, args...); err != nil {
if err == sql.ErrNoRows {
return nil, store.NewErrNotFound("FileInfo", id)
}
return nil, errors.Wrapf(err, "failed to get FileInfo with id=%s", id)
}
return info, nil
}
func (fs SqlFileInfoStore) Get(id string) (*model.FileInfo, error) {
return fs.get(id, false)
}
func (fs SqlFileInfoStore) GetFromMaster(id string) (*model.FileInfo, error) {
return fs.get(id, true)
}
func (fs SqlFileInfoStore) GetWithOptions(page, perPage int, opt *model.GetFileInfosOptions) ([]*model.FileInfo, error) {
if perPage < 0 {
return nil, store.NewErrLimitExceeded("perPage", perPage, "value used in pagination while getting FileInfos")
} else if page < 0 {
return nil, store.NewErrLimitExceeded("page", page, "value used in pagination while getting FileInfos")
}
if perPage == 0 {
return nil, nil
}
if opt == nil {
opt = &model.GetFileInfosOptions{}
}
query := fs.getQueryBuilder().
Select(fs.queryFields...).
From("FileInfo")
if len(opt.ChannelIds) > 0 {
query = query.Where(sq.Eq{"FileInfo.ChannelId": opt.ChannelIds})
}
if len(opt.UserIds) > 0 {
query = query.Where(sq.Eq{"FileInfo.CreatorId": opt.UserIds})
}
if opt.Since > 0 {
query = query.Where(sq.GtOrEq{"FileInfo.CreateAt": opt.Since})
}
if !opt.IncludeDeleted {
query = query.Where("FileInfo.DeleteAt = 0")
}
if opt.OnlyEmptyContent {
query = query.Where("(FileInfo.Content IS NULL OR FileInfo.Content = '')")
}
if opt.SortBy == "" {
opt.SortBy = model.FileinfoSortByCreated
}
sortDirection := "ASC"
if opt.SortDescending {
sortDirection = "DESC"
}
switch opt.SortBy {
case model.FileinfoSortByCreated:
query = query.OrderBy("FileInfo.CreateAt " + sortDirection)
case model.FileinfoSortBySize:
query = query.OrderBy("FileInfo.Size " + sortDirection)
default:
return nil, store.NewErrInvalidInput("FileInfo", "<sortOption>", opt.SortBy)
}
query = query.OrderBy("FileInfo.Id ASC") // secondary sort for sort stability
query = query.Limit(uint64(perPage)).Offset(uint64(perPage * page))
queryString, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrap(err, "file_info_tosql")
}
infos := []*model.FileInfo{}
if err := fs.GetReplica().Select(&infos, queryString, args...); err != nil {
return nil, errors.Wrap(err, "failed to find FileInfos")
}
return infos, nil
}
func (fs SqlFileInfoStore) GetByPath(path string) (*model.FileInfo, error) {
info := &model.FileInfo{}
query := fs.getQueryBuilder().
Select(fs.queryFields...).
From("FileInfo").
Where(sq.Eq{"Path": path}).
Where(sq.Eq{"DeleteAt": 0}).
Limit(1)
queryString, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrap(err, "file_info_tosql")
}
if err := fs.GetReplica().Get(info, queryString, args...); err != nil {
if err == sql.ErrNoRows {
return nil, store.NewErrNotFound("FileInfo", fmt.Sprintf("path=%s", path))
}
return nil, errors.Wrapf(err, "failed to get FileInfo with path=%s", path)
}
return info, nil
}
func (fs SqlFileInfoStore) InvalidateFileInfosForPostCache(postId string, deleted bool) {
}
func (fs SqlFileInfoStore) GetForPost(postId string, readFromMaster, includeDeleted, allowFromCache bool) ([]*model.FileInfo, error) {
infos := []*model.FileInfo{}
dbmap := fs.GetReplica()
if readFromMaster {
dbmap = fs.GetMaster()
}
query := fs.getQueryBuilder().
Select(fs.queryFields...).
From("FileInfo").
Where(sq.Eq{"PostId": postId}).
OrderBy("CreateAt")
if !includeDeleted {
query = query.Where("DeleteAt = 0")
}
queryString, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrap(err, "file_info_tosql")
}
if err := dbmap.Select(&infos, queryString, args...); err != nil {
return nil, errors.Wrapf(err, "failed to find FileInfos with postId=%s", postId)
}
return infos, nil
}
func (fs SqlFileInfoStore) GetForUser(userId string) ([]*model.FileInfo, error) {
infos := []*model.FileInfo{}
query := fs.getQueryBuilder().
Select(fs.queryFields...).
From("FileInfo").
Where(sq.Eq{"CreatorId": userId}).
Where(sq.Eq{"DeleteAt": 0}).
OrderBy("CreateAt")
queryString, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrap(err, "file_info_tosql")
}
if err := fs.GetReplica().Select(&infos, queryString, args...); err != nil {
return nil, errors.Wrapf(err, "failed to find FileInfos with creatorId=%s", userId)
}
return infos, nil
}
func (fs SqlFileInfoStore) AttachToPost(rctx request.CTX, fileId, postId, channelId, creatorId string) error {
query := fs.getQueryBuilder().
Update("FileInfo").
Set("PostId", postId).
Set("ChannelId", channelId).
Where(sq.And{
sq.Eq{"Id": fileId},
sq.Eq{"PostId": ""},
sq.Or{
sq.Eq{"CreatorId": creatorId},
sq.Eq{"CreatorId": "nouser"},
},
})
queryString, args, err := query.ToSql()
if err != nil {
return errors.Wrap(err, "file_info_tosql")
}
sqlResult, err := fs.GetMaster().Exec(queryString, args...)
if err != nil {
return errors.Wrapf(err, "failed to update FileInfo with id=%s and postId=%s", fileId, postId)
}
count, err := sqlResult.RowsAffected()
if err != nil {
return errors.Wrap(err, "unable to retrieve rows affected")
} else if count == 0 {
// Could not attach the file to the post
return store.NewErrInvalidInput("FileInfo", "<id, postId, creatorId>", fmt.Sprintf("<%s, %s, %s>", fileId, postId, creatorId))
}
return nil
}
func (fs SqlFileInfoStore) SetContent(rctx request.CTX, fileId, content string) error {
query := fs.getQueryBuilder().
Update("FileInfo").
Set("Content", content).
Where(sq.Eq{"Id": fileId})
queryString, args, err := query.ToSql()
if err != nil {
return errors.Wrap(err, "file_info_tosql")
}
_, err = fs.GetMaster().Exec(queryString, args...)
if err != nil {
return errors.Wrapf(err, "failed to update FileInfo content with id=%s", fileId)
}
return nil
}
func (fs SqlFileInfoStore) DeleteForPost(rctx request.CTX, postId string) (string, error) {
if _, err := fs.GetMaster().Exec(
`UPDATE
FileInfo
SET
DeleteAt = ?
WHERE
PostId = ?`, model.GetMillis(), postId); err != nil {
return "", errors.Wrapf(err, "failed to update FileInfo with postId=%s", postId)
}
return postId, nil
}
func (fs SqlFileInfoStore) DeleteForPostByIds(rctx request.CTX, postId string, fileIDs []string) error {
query := fs.getQueryBuilder().
Update("FileInfo").
Set("DeleteAt", model.GetMillis()).
Where(sq.Eq{
"PostId": postId,
"Id": fileIDs,
})
queryString, args, err := query.ToSql()
if err != nil {
return errors.Wrap(err, "SqlFileInfoStore.DeleteForPostByIds: failed to generate sql from query")
}
if _, err := fs.GetMaster().Exec(queryString, args...); err != nil {
return errors.Wrap(err, "SqlFileInfoStore.DeleteForPostByIds: failed to soft delete FileInfo from database")
}
return nil
}
func (fs SqlFileInfoStore) PermanentDeleteForPost(rctx request.CTX, postID string) error {
if _, err := fs.GetMaster().Exec(`DELETE FROM FileInfo WHERE PostId = ?`, postID); err != nil {
return errors.Wrapf(err, "failed to delete FileInfo with PostId=%s", postID)
}
return nil
}
func (fs SqlFileInfoStore) PermanentDelete(rctx request.CTX, fileId string) error {
if _, err := fs.GetMaster().Exec(`DELETE FROM FileInfo WHERE Id = ?`, fileId); err != nil {
return errors.Wrapf(err, "failed to delete FileInfo with id=%s", fileId)
}
return nil
}
func (fs SqlFileInfoStore) PermanentDeleteBatch(rctx request.CTX, endTime int64, limit int64) (int64, error) {
query := "DELETE from FileInfo WHERE Id = any (array (SELECT Id FROM FileInfo WHERE CreateAt < ? AND CreatorId != ? LIMIT ?))"
sqlResult, err := fs.GetMaster().Exec(query, endTime, model.BookmarkFileOwner, limit)
if err != nil {
return 0, errors.Wrap(err, "failed to delete FileInfos in batch")
}
rowsAffected, err := sqlResult.RowsAffected()
if err != nil {
return 0, errors.Wrapf(err, "unable to retrieve rows affected")
}
return rowsAffected, nil
}
func (fs SqlFileInfoStore) PermanentDeleteByUser(rctx request.CTX, userId string) (int64, error) {
query := "DELETE from FileInfo WHERE CreatorId = ?"
sqlResult, err := fs.GetMaster().Exec(query, userId)
if err != nil {
return 0, errors.Wrapf(err, "failed to delete FileInfo with creatorId=%s", userId)
}
rowsAffected, err := sqlResult.RowsAffected()
if err != nil {
return 0, errors.Wrapf(err, "unable to retrieve rows affected")
}
return rowsAffected, nil
}
func (fs SqlFileInfoStore) Search(rctx request.CTX, paramsList []*model.SearchParams, userId, teamId string, page, perPage int) (*model.FileInfoList, error) {
// Since we don't support paging for DB search, we just return nothing for later pages
if page > 0 {
return model.NewFileInfoList(), nil
}
if err := model.IsSearchParamsListValid(paramsList); err != nil {
return nil, err
}
query := fs.getQueryBuilder().
Select(fs.queryFields...).
From("FileInfo").
LeftJoin("Channels as C ON C.Id=FileInfo.ChannelId").
LeftJoin("ChannelMembers as CM ON C.Id=CM.ChannelId").
Where(sq.Eq{"FileInfo.DeleteAt": 0}).
Where(sq.Or{
sq.Eq{"FileInfo.CreatorId": model.BookmarkFileOwner},
sq.NotEq{"FileInfo.PostId": ""},
}).
Where(sq.Expr("NOT EXISTS (SELECT 1 FROM TemporaryPosts WHERE TemporaryPosts.PostId = FileInfo.PostId)")).
OrderBy("FileInfo.CreateAt DESC").
Limit(100)
if teamId != "" {
query = query.Where(sq.Or{sq.Eq{"C.TeamId": teamId}, sq.Eq{"C.TeamId": ""}})
}
for _, params := range paramsList {
params.Terms = removeNonAlphaNumericUnquotedTerms(params.Terms, " ")
if !params.IncludeDeletedChannels {
query = query.Where(sq.Eq{"C.DeleteAt": 0})
}
if !params.SearchWithoutUserId {
query = query.Where(sq.Eq{"CM.UserId": userId})
}
if len(params.InChannels) != 0 {
query = query.Where(sq.Eq{"C.Id": params.InChannels})
}
if len(params.Extensions) != 0 {
query = query.Where(sq.Eq{"FileInfo.Extension": params.Extensions})
}
if len(params.ExcludedExtensions) != 0 {
query = query.Where(sq.NotEq{"FileInfo.Extension": params.ExcludedExtensions})
}
if len(params.ExcludedChannels) != 0 {
query = query.Where(sq.NotEq{"C.Id": params.ExcludedChannels})
}
if len(params.FromUsers) != 0 {
query = query.Where(sq.Eq{"FileInfo.CreatorId": params.FromUsers})
}
if len(params.ExcludedUsers) != 0 {
query = query.Where(sq.NotEq{"FileInfo.CreatorId": params.ExcludedUsers})
}
// handle after: before: on: filters
if params.OnDate != "" {
onDateStart, onDateEnd := params.GetOnDateMillis()
query = query.Where(sq.Expr("FileInfo.CreateAt BETWEEN ? AND ?", strconv.FormatInt(onDateStart, 10), strconv.FormatInt(onDateEnd, 10)))
} else {
if params.ExcludedDate != "" {
excludedDateStart, excludedDateEnd := params.GetExcludedDateMillis()
query = query.Where(sq.Expr("FileInfo.CreateAt NOT BETWEEN ? AND ?", strconv.FormatInt(excludedDateStart, 10), strconv.FormatInt(excludedDateEnd, 10)))
}
if params.AfterDate != "" {
afterDate := params.GetAfterDateMillis()
query = query.Where(sq.GtOrEq{"FileInfo.CreateAt": strconv.FormatInt(afterDate, 10)})
}
if params.BeforeDate != "" {
beforeDate := params.GetBeforeDateMillis()
query = query.Where(sq.LtOrEq{"FileInfo.CreateAt": strconv.FormatInt(beforeDate, 10)})
}
if params.ExcludedAfterDate != "" {
afterDate := params.GetExcludedAfterDateMillis()
query = query.Where(sq.Lt{"FileInfo.CreateAt": strconv.FormatInt(afterDate, 10)})
}
if params.ExcludedBeforeDate != "" {
beforeDate := params.GetExcludedBeforeDateMillis()
query = query.Where(sq.Gt{"FileInfo.CreateAt": strconv.FormatInt(beforeDate, 10)})
}
}
terms := params.Terms
excludedTerms := params.ExcludedTerms
for _, c := range specialSearchChars {
terms = strings.Replace(terms, c, " ", -1)
excludedTerms = strings.Replace(excludedTerms, c, " ", -1)
}
// Unlike post message search, filenames commonly have a hyphenated
// name glued directly to an extension (e.g. "photo-2024.jpg"), which
// PostgreSQL's parser classifies as an opaque "host"-type token that
// to_tsquery can never match against a hyphen-preserving compound
// query. So, unlike post_store.go, hyphens are still replaced with
// spaces here rather than preserved.
terms = strings.Replace(terms, "-", " ", -1)
excludedTerms = strings.Replace(excludedTerms, "-", " ", -1)
if terms == "" && excludedTerms == "" {
// we've already confirmed that we have a channel or user to search for
} else {
// Parse text for wildcards
if wildcard, err := regexp.Compile(`\*($| )`); err == nil {
terms = wildcard.ReplaceAllLiteralString(terms, ":* ")
excludedTerms = wildcard.ReplaceAllLiteralString(excludedTerms, ":* ")
}
excludeClause := ""
if excludedTerms != "" {
excludeClause = " & !(" + strings.Join(strings.Fields(excludedTerms), " | ") + ")"
}
queryTerms := ""
if params.OrTerms {
queryTerms = "(" + strings.Join(strings.Fields(terms), " | ") + ")" + excludeClause
} else {
queryTerms = "(" + strings.Join(strings.Fields(terms), " & ") + ")" + excludeClause
}
query = query.Where(sq.Or{
sq.Expr(fmt.Sprintf("to_tsvector('%[1]s', FileInfo.Name) @@ to_tsquery('%[1]s', ?)", fs.pgDefaultTextSearchConfig), queryTerms),
sq.Expr(fmt.Sprintf("to_tsvector('%[1]s', Translate(FileInfo.Name, '.,-', ' ')) @@ to_tsquery('%[1]s', ?)", fs.pgDefaultTextSearchConfig), queryTerms),
sq.Expr(fmt.Sprintf("to_tsvector('%[1]s', FileInfo.Content) @@ to_tsquery('%[1]s', ?)", fs.pgDefaultTextSearchConfig), queryTerms),
})
}
}
queryString, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrap(err, "file_info_tosql")
}
list := model.NewFileInfoList()
items := []fileInfoWithChannelID{}
err = fs.GetSearchReplicaX().Select(&items, queryString, args...)
if err != nil {
rctx.Logger().Warn("Query error searching files.", 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 _, item := range items {
info := item.ToModel()
list.AddFileInfo(info)
list.AddOrder(info.Id)
}
}
list.MakeNonNil()
return list, nil
}
func (fs SqlFileInfoStore) CountAll() (int64, error) {
query := fs.getQueryBuilder().
Select("num").
From("file_stats")
var count int64
err := fs.GetReplica().GetBuilder(&count, query)
if err != nil {
return int64(0), errors.Wrap(err, "failed to count Files")
}
return count, nil
}
func (fs SqlFileInfoStore) GetFilesBatchForIndexing(startTime int64, startFileID string, includeDeleted bool, limit int) ([]*model.FileForIndexing, error) {
files := []*model.FileForIndexing{}
query := fs.getQueryBuilder().
Select(fs.queryFields...).
From("FileInfo").
Where("(FileInfo.CreateAt, FileInfo.Id) > (?, ?)", startTime, startFileID).
OrderBy("FileInfo.CreateAt ASC, FileInfo.Id ASC").
Limit(uint64(limit))
if !includeDeleted {
query = query.Where(sq.Eq{"FileInfo.DeleteAt": 0})
}
err := fs.GetSearchReplicaX().SelectBuilder(&files, query)
if err != nil {
return nil, errors.Wrap(err, "failed to find Files")
}
return files, nil
}
func (fs SqlFileInfoStore) GetStorageUsage(_, includeDeleted bool) (int64, error) {
var query sq.SelectBuilder
if !includeDeleted {
query = fs.getQueryBuilder().
Select("usage").
From("file_stats")
} else {
query = fs.getQueryBuilder().
Select("COALESCE(SUM(Size), 0)").
From("FileInfo")
}
var size int64
err := fs.GetReplica().GetBuilder(&size, query)
if err != nil {
return int64(0), errors.Wrap(err, "failed to get storage usage")
}
return size, nil
}
// GetUptoNSizeFileTime returns the CreateAt time of the last accessible file with a running-total size upto n bytes.
func (fs *SqlFileInfoStore) GetUptoNSizeFileTime(n int64) (int64, error) {
if n <= 0 {
return 0, errors.New("n can't be less than 1")
}
sizeSubQuery := sq.
Select("SUM(fi.Size) OVER(ORDER BY CreateAt DESC, fi.Id) RunningTotal", "fi.CreateAt").
From("FileInfo fi").
Where(sq.Eq{"fi.DeleteAt": 0})
builder := fs.getQueryBuilder().
Select("fi2.CreateAt").
FromSelect(sizeSubQuery, "fi2").
Where(sq.LtOrEq{"fi2.RunningTotal": n}).
OrderBy("fi2.CreateAt").
Limit(1)
query, queryArgs, err := builder.ToSql()
if err != nil {
return 0, errors.Wrap(err, "GetUptoNSizeFileTime_tosql")
}
var createAt int64
if err := fs.GetReplica().Get(&createAt, query, queryArgs...); err != nil {
if err == sql.ErrNoRows {
return 0, store.NewErrNotFound("File", "none")
}
return 0, errors.Wrapf(err, "failed to get the File for size upto=%d", n)
}
return createAt, nil
}
func (fs SqlFileInfoStore) RestoreForPostByIds(rctx request.CTX, postId string, fileIDs []string) error {
query := fs.getQueryBuilder().
Update("FileInfo").
Set("DeleteAt", 0).
Where(sq.Eq{
"PostId": postId,
"Id": fileIDs,
})
queryString, args, err := query.ToSql()
if err != nil {
return errors.Wrap(err, "SqlFileInfoStore.RestoreForPostByIds: failed to generate sql from query")
}
if _, err := fs.GetMaster().Exec(queryString, args...); err != nil {
return errors.Wrap(err, "SqlFileInfoStore.RestoreForPostByIds: failed to undelete FileInfo from database")
}
return nil
}
func (fs SqlFileInfoStore) RefreshFileStats() error {
ctx, cancel := fs.analyticsContext()
defer cancel()
// 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 := fs.GetMaster().ExecContext(ctx, "REFRESH MATERIALIZED VIEW file_stats"); err != nil {
return errors.Wrap(err, "error refreshing materialized view file_stats")
}
return nil
}

View File

@@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/searchtest"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestFileInfoStore(t *testing.T) {
StoreTestWithSqlStore(t, storetest.TestFileInfoStore)
}
func TestSearchFileInfoStore(t *testing.T) {
StoreTestWithSearchTestEngine(t, searchtest.TestSearchFileInfoStore)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestGroupStore(t *testing.T) {
StoreTest(t, storetest.TestGroupStore)
}

View File

@@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
var enableFullyParallelTests bool
func InitTest(logger mlog.LoggerIFace, parallelism int) {
enableFullyParallelTests = parallelism > 1
initStores(logger, parallelism)
}
func TearDownTest() {
tearDownStores()
}

View File

@@ -0,0 +1,546 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
sq "github.com/mattermost/squirrel"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
type relationalCheckConfig struct {
parentName string
parentIdAttr string
childName string
childIdAttr string
canParentIdBeEmpty bool
sortRecords bool
filter any
}
func getOrphanedRecords(ss *SqlStore, cfg relationalCheckConfig) ([]model.OrphanedRecord, error) {
records := []model.OrphanedRecord{}
sub := ss.getQueryBuilder().
Select("TRUE").
From(cfg.parentName + " AS PT").
Prefix("NOT EXISTS (").
Suffix(")").
Where("PT.id = CT." + cfg.parentIdAttr)
main := ss.getQueryBuilder().
Select().
Column("CT." + cfg.parentIdAttr + " AS ParentId").
From(cfg.childName + " AS CT").
Where(sub)
if cfg.childIdAttr != "" {
main = main.Column("CT." + cfg.childIdAttr + " AS ChildId")
}
if cfg.canParentIdBeEmpty {
main = main.Where(sq.NotEq{"CT." + cfg.parentIdAttr: ""})
}
if cfg.filter != nil {
main = main.Where(cfg.filter)
}
if cfg.sortRecords {
main = main.OrderBy("CT." + cfg.parentIdAttr)
}
query, args, err := main.ToSql()
if err != nil {
return nil, err
}
err = ss.GetMaster().Select(&records, query, args...)
return records, err
}
func checkParentChildIntegrity(ss *SqlStore, config relationalCheckConfig) model.IntegrityCheckResult {
var result model.IntegrityCheckResult
var data model.RelationalIntegrityCheckData
config.sortRecords = true
data.Records, result.Err = getOrphanedRecords(ss, config)
if result.Err != nil {
mlog.Error("Error while getting orphaned records", mlog.Err(result.Err))
return result
}
data.ParentName = config.parentName
data.ChildName = config.childName
data.ParentIdAttr = config.parentIdAttr
data.ChildIdAttr = config.childIdAttr
result.Data = data
return result
}
func checkChannelsCommandWebhooksIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Channels",
parentIdAttr: "ChannelId",
childName: "CommandWebhooks",
childIdAttr: "Id",
})
}
func checkChannelsChannelMemberHistoryIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Channels",
parentIdAttr: "ChannelId",
childName: "ChannelMemberHistory",
childIdAttr: "",
})
}
func checkChannelsChannelMembersIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Channels",
parentIdAttr: "ChannelId",
childName: "ChannelMembers",
childIdAttr: "",
})
}
func checkChannelsIncomingWebhooksIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Channels",
parentIdAttr: "ChannelId",
childName: "IncomingWebhooks",
childIdAttr: "Id",
})
}
func checkChannelsOutgoingWebhooksIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Channels",
parentIdAttr: "ChannelId",
childName: "OutgoingWebhooks",
childIdAttr: "Id",
})
}
func checkChannelsPostsIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Channels",
parentIdAttr: "ChannelId",
childName: "Posts",
childIdAttr: "Id",
})
}
func checkChannelsFileInfoIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Channels",
parentIdAttr: "ChannelId",
childName: "FileInfo",
childIdAttr: "Id",
})
}
func checkCommandsCommandWebhooksIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Commands",
parentIdAttr: "CommandId",
childName: "CommandWebhooks",
childIdAttr: "Id",
})
}
func checkPostsFileInfoIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Posts",
parentIdAttr: "PostId",
childName: "FileInfo",
childIdAttr: "Id",
})
}
func checkPostsPostsRootIdIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Posts",
parentIdAttr: "RootId",
childName: "Posts",
childIdAttr: "Id",
canParentIdBeEmpty: true,
})
}
func checkPostsReactionsIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Posts",
parentIdAttr: "PostId",
childName: "Reactions",
childIdAttr: "",
})
}
func checkSchemesChannelsIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Schemes",
parentIdAttr: "SchemeId",
childName: "Channels",
childIdAttr: "Id",
canParentIdBeEmpty: true,
})
}
func checkSchemesTeamsIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Schemes",
parentIdAttr: "SchemeId",
childName: "Teams",
childIdAttr: "Id",
canParentIdBeEmpty: true,
})
}
func checkSessionsAuditsIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Sessions",
parentIdAttr: "SessionId",
childName: "Audits",
childIdAttr: "Id",
canParentIdBeEmpty: true,
})
}
func checkTeamsChannelsIntegrity(ss *SqlStore) model.IntegrityCheckResult {
res1 := checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Teams",
parentIdAttr: "TeamId",
childName: "Channels",
childIdAttr: "Id",
filter: sq.NotEq{"CT.Type": []model.ChannelType{model.ChannelTypeDirect, model.ChannelTypeGroup}},
})
res2 := checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Teams",
parentIdAttr: "TeamId",
childName: "Channels",
childIdAttr: "Id",
canParentIdBeEmpty: true,
filter: sq.Eq{"CT.Type": []model.ChannelType{model.ChannelTypeDirect, model.ChannelTypeGroup}},
})
data1 := res1.Data.(model.RelationalIntegrityCheckData)
data2 := res2.Data.(model.RelationalIntegrityCheckData)
data1.Records = append(data1.Records, data2.Records...)
res1.Data = data1
return res1
}
func checkTeamsCommandsIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Teams",
parentIdAttr: "TeamId",
childName: "Commands",
childIdAttr: "Id",
})
}
func checkTeamsIncomingWebhooksIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Teams",
parentIdAttr: "TeamId",
childName: "IncomingWebhooks",
childIdAttr: "Id",
})
}
func checkTeamsOutgoingWebhooksIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Teams",
parentIdAttr: "TeamId",
childName: "OutgoingWebhooks",
childIdAttr: "Id",
})
}
func checkTeamsTeamMembersIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Teams",
parentIdAttr: "TeamId",
childName: "TeamMembers",
childIdAttr: "",
})
}
func checkUsersAuditsIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Users",
parentIdAttr: "UserId",
childName: "Audits",
childIdAttr: "Id",
canParentIdBeEmpty: true,
})
}
func checkUsersCommandWebhooksIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Users",
parentIdAttr: "UserId",
childName: "CommandWebhooks",
childIdAttr: "Id",
})
}
func checkUsersChannelMemberHistoryIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Users",
parentIdAttr: "UserId",
childName: "ChannelMemberHistory",
childIdAttr: "",
})
}
func checkUsersChannelMembersIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Users",
parentIdAttr: "UserId",
childName: "ChannelMembers",
childIdAttr: "",
})
}
func checkUsersChannelsIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Users",
parentIdAttr: "CreatorId",
childName: "Channels",
childIdAttr: "Id",
canParentIdBeEmpty: true,
})
}
func checkUsersCommandsIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Users",
parentIdAttr: "CreatorId",
childName: "Commands",
childIdAttr: "Id",
})
}
func checkUsersCompliancesIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Users",
parentIdAttr: "UserId",
childName: "Compliances",
childIdAttr: "Id",
})
}
func checkUsersEmojiIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Users",
parentIdAttr: "CreatorId",
childName: "Emoji",
childIdAttr: "Id",
})
}
func checkUsersFileInfoIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Users",
parentIdAttr: "CreatorId",
childName: "FileInfo",
childIdAttr: "Id",
})
}
func checkUsersIncomingWebhooksIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Users",
parentIdAttr: "UserId",
childName: "IncomingWebhooks",
childIdAttr: "Id",
})
}
func checkUsersOAuthAccessDataIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Users",
parentIdAttr: "UserId",
childName: "OAuthAccessData",
childIdAttr: "Token",
})
}
func checkUsersOAuthAppsIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Users",
parentIdAttr: "CreatorId",
childName: "OAuthApps",
childIdAttr: "Id",
})
}
func checkUsersOAuthAuthDataIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Users",
parentIdAttr: "UserId",
childName: "OAuthAuthData",
childIdAttr: "Code",
})
}
func checkUsersOutgoingWebhooksIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Users",
parentIdAttr: "CreatorId",
childName: "OutgoingWebhooks",
childIdAttr: "Id",
})
}
func checkUsersPostsIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Users",
parentIdAttr: "UserId",
childName: "Posts",
childIdAttr: "Id",
})
}
func checkUsersPreferencesIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Users",
parentIdAttr: "UserId",
childName: "Preferences",
childIdAttr: "",
})
}
func checkUsersReactionsIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Users",
parentIdAttr: "UserId",
childName: "Reactions",
childIdAttr: "",
})
}
func checkUsersSessionsIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Users",
parentIdAttr: "UserId",
childName: "Sessions",
childIdAttr: "Id",
})
}
func checkUsersStatusIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Users",
parentIdAttr: "UserId",
childName: "Status",
childIdAttr: "",
})
}
func checkUsersTeamMembersIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Users",
parentIdAttr: "UserId",
childName: "TeamMembers",
childIdAttr: "",
})
}
func checkUsersUserAccessTokensIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Users",
parentIdAttr: "UserId",
childName: "UserAccessTokens",
childIdAttr: "Id",
})
}
func checkChannelsIntegrity(ss *SqlStore, results chan<- model.IntegrityCheckResult) {
results <- checkChannelsCommandWebhooksIntegrity(ss)
results <- checkChannelsChannelMemberHistoryIntegrity(ss)
results <- checkChannelsChannelMembersIntegrity(ss)
results <- checkChannelsIncomingWebhooksIntegrity(ss)
results <- checkChannelsOutgoingWebhooksIntegrity(ss)
results <- checkChannelsPostsIntegrity(ss)
results <- checkChannelsFileInfoIntegrity(ss)
}
func checkCommandsIntegrity(ss *SqlStore, results chan<- model.IntegrityCheckResult) {
results <- checkCommandsCommandWebhooksIntegrity(ss)
}
func checkPostsIntegrity(ss *SqlStore, results chan<- model.IntegrityCheckResult) {
results <- checkPostsFileInfoIntegrity(ss)
results <- checkPostsPostsRootIdIntegrity(ss)
results <- checkPostsReactionsIntegrity(ss)
results <- checkThreadsTeamsIntegrity(ss)
}
func checkSchemesIntegrity(ss *SqlStore, results chan<- model.IntegrityCheckResult) {
results <- checkSchemesChannelsIntegrity(ss)
results <- checkSchemesTeamsIntegrity(ss)
}
func checkSessionsIntegrity(ss *SqlStore, results chan<- model.IntegrityCheckResult) {
results <- checkSessionsAuditsIntegrity(ss)
}
func checkTeamsIntegrity(ss *SqlStore, results chan<- model.IntegrityCheckResult) {
results <- checkTeamsChannelsIntegrity(ss)
results <- checkTeamsCommandsIntegrity(ss)
results <- checkTeamsIncomingWebhooksIntegrity(ss)
results <- checkTeamsOutgoingWebhooksIntegrity(ss)
results <- checkTeamsTeamMembersIntegrity(ss)
}
func checkUsersIntegrity(ss *SqlStore, results chan<- model.IntegrityCheckResult) {
results <- checkUsersAuditsIntegrity(ss)
results <- checkUsersCommandWebhooksIntegrity(ss)
results <- checkUsersChannelMemberHistoryIntegrity(ss)
results <- checkUsersChannelMembersIntegrity(ss)
results <- checkUsersChannelsIntegrity(ss)
results <- checkUsersCommandsIntegrity(ss)
results <- checkUsersCompliancesIntegrity(ss)
results <- checkUsersEmojiIntegrity(ss)
results <- checkUsersFileInfoIntegrity(ss)
results <- checkUsersIncomingWebhooksIntegrity(ss)
results <- checkUsersOAuthAccessDataIntegrity(ss)
results <- checkUsersOAuthAppsIntegrity(ss)
results <- checkUsersOAuthAuthDataIntegrity(ss)
results <- checkUsersOutgoingWebhooksIntegrity(ss)
results <- checkUsersPostsIntegrity(ss)
results <- checkUsersPreferencesIntegrity(ss)
results <- checkUsersReactionsIntegrity(ss)
results <- checkUsersSessionsIntegrity(ss)
results <- checkUsersStatusIntegrity(ss)
results <- checkUsersTeamMembersIntegrity(ss)
results <- checkUsersUserAccessTokensIntegrity(ss)
}
func checkThreadsTeamsIntegrity(ss *SqlStore) model.IntegrityCheckResult {
return checkParentChildIntegrity(ss, relationalCheckConfig{
parentName: "Teams",
parentIdAttr: "ThreadTeamId",
childName: "Threads",
childIdAttr: "PostId",
canParentIdBeEmpty: false,
})
}
func CheckRelationalIntegrity(ss *SqlStore, results chan<- model.IntegrityCheckResult) {
mlog.Info("Starting relational integrity checks...")
checkChannelsIntegrity(ss, results)
checkCommandsIntegrity(ss, results)
checkPostsIntegrity(ss, results)
checkSchemesIntegrity(ss, results)
checkSessionsIntegrity(ss, results)
checkTeamsIntegrity(ss, results)
checkUsersIntegrity(ss, results)
mlog.Info("Done with relational integrity checks")
close(results)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,549 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/lib/pq"
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
const (
jobsCleanupDelay = 100 * time.Millisecond
)
type SqlJobStore struct {
*SqlStore
jobColumns []string
jobQuery sq.SelectBuilder
}
func newSqlJobStore(sqlStore *SqlStore) store.JobStore {
s := &SqlJobStore{
SqlStore: sqlStore,
jobColumns: []string{
"Id",
"Type",
"Priority",
"CreateAt",
"StartAt",
"LastActivityAt",
"Status",
"Progress",
"Data",
},
}
s.jobQuery = s.getQueryBuilder().
Select(s.jobColumns...).
From("Jobs")
return s
}
func (jss SqlJobStore) Save(job *model.Job) (*model.Job, error) {
jsonData, err := json.Marshal(job.Data)
if err != nil {
return nil, errors.Wrap(err, "failed marshalling job data")
}
if jss.IsBinaryParamEnabled() {
jsonData = AppendBinaryFlag(jsonData)
}
query := jss.getQueryBuilder().
Insert("Jobs").
Columns("Id", "Type", "Priority", "CreateAt", "StartAt", "LastActivityAt", "Status", "Progress", "Data").
Values(job.Id, job.Type, job.Priority, job.CreateAt, job.StartAt, job.LastActivityAt, job.Status, job.Progress, jsonData)
queryString, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrap(err, "failed to generate sqlquery")
}
if _, err = jss.GetMaster().Exec(queryString, args...); err != nil {
return nil, errors.Wrap(err, "failed to save Job")
}
return job, nil
}
func (jss SqlJobStore) SaveOnce(job *model.Job) (*model.Job, error) {
jsonData, err := json.Marshal(job.Data)
if err != nil {
return nil, errors.Wrap(err, "failed marshalling job data")
}
if jss.IsBinaryParamEnabled() {
jsonData = AppendBinaryFlag(jsonData)
}
tx, err := jss.GetMaster().BeginWithIsolation(&sql.TxOptions{
Isolation: sql.LevelSerializable,
})
if err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
defer finalizeTransactionX(tx, &err)
query, args, err := jss.getQueryBuilder().
Select("COUNT(*)").
From("Jobs").
Where(sq.Eq{
"Status": []string{model.JobStatusPending, model.JobStatusInProgress},
"Type": job.Type,
}).ToSql()
if err != nil {
return nil, errors.Wrap(err, "job_tosql")
}
var count int64
err = tx.Get(&count, query, args...)
if err != nil {
return nil, errors.Wrapf(err, "failed to count pending and in-progress jobs with type=%s", job.Type)
}
if count > 0 {
return nil, nil
}
query, args, err = jss.getQueryBuilder().
Insert("Jobs").
Columns("Id", "Type", "Priority", "CreateAt", "StartAt", "LastActivityAt", "Status", "Progress", "Data").
Values(job.Id, job.Type, job.Priority, job.CreateAt, job.StartAt, job.LastActivityAt, job.Status, job.Progress, jsonData).ToSql()
if err != nil {
return nil, errors.Wrap(err, "failed to generate sqlquery")
}
if _, err = tx.Exec(query, args...); err != nil {
if isRepeatableError(err) {
return nil, nil
}
return nil, errors.Wrap(err, "failed to save Job")
}
if err = tx.Commit(); err != nil {
if isRepeatableError(err) {
return nil, nil
}
return nil, errors.Wrap(err, "commit_transaction")
}
return job, nil
}
// SaveOnceByTypeAndData inserts the job only when no pending or in-progress job already exists
// with the same type and matching data filter. Unlike SaveOnce (which dedupes per type), this
// allows many concurrent jobs of the same type while keeping at most one queued per entity
// identified by the data filter (e.g. one job per scheduled_recap_id). Returns (nil, nil) when a
// matching job already exists.
func (jss SqlJobStore) SaveOnceByTypeAndData(job *model.Job, data map[string]string) (*model.Job, error) {
if len(data) == 0 {
return nil, errors.New("data filter cannot be empty")
}
jsonData, err := json.Marshal(job.Data)
if err != nil {
return nil, errors.Wrap(err, "failed marshalling job data")
}
if jss.IsBinaryParamEnabled() {
jsonData = AppendBinaryFlag(jsonData)
}
tx, err := jss.GetMaster().BeginWithIsolation(&sql.TxOptions{
Isolation: sql.LevelSerializable,
})
if err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
defer finalizeTransactionX(tx, &err)
query, args, err := jss.jobTypeAndDataQuery(
jss.getQueryBuilder().Select("COUNT(*)").From("Jobs"),
job.Type,
data,
model.JobStatusPending,
model.JobStatusInProgress,
).ToSql()
if err != nil {
return nil, errors.Wrap(err, "job_tosql")
}
var count int64
err = tx.Get(&count, query, args...)
if err != nil {
return nil, errors.Wrapf(err, "failed to count pending and in-progress jobs with type=%s and data filter", job.Type)
}
if count > 0 {
return nil, nil
}
query, args, err = jss.getQueryBuilder().
Insert("Jobs").
Columns("Id", "Type", "Priority", "CreateAt", "StartAt", "LastActivityAt", "Status", "Progress", "Data").
Values(job.Id, job.Type, job.Priority, job.CreateAt, job.StartAt, job.LastActivityAt, job.Status, job.Progress, jsonData).ToSql()
if err != nil {
return nil, errors.Wrap(err, "failed to generate sqlquery")
}
if _, err = tx.Exec(query, args...); err != nil {
if isRepeatableError(err) {
return nil, nil
}
return nil, errors.Wrap(err, "failed to save Job")
}
if err = tx.Commit(); err != nil {
if isRepeatableError(err) {
return nil, nil
}
return nil, errors.Wrap(err, "commit_transaction")
}
return job, nil
}
// UpdateOptimistically updates the job only if its current status matches currentStatus.
// Returns the updated job on success, or nil if no row was matched (status mismatch or job not
// found). A nil return with a nil error is not an error — it means the precondition was not met.
func (jss SqlJobStore) UpdateOptimistically(job *model.Job, currentStatus string) (*model.Job, error) {
dataJSON, jsonErr := json.Marshal(job.Data)
if jsonErr != nil {
return nil, errors.Wrap(jsonErr, "failed to encode job's data to JSON")
}
if jss.IsBinaryParamEnabled() {
dataJSON = AppendBinaryFlag(dataJSON)
}
builder := jss.getQueryBuilder().
Update("Jobs").
Set("LastActivityAt", model.GetMillis()).
Set("Status", job.Status).
Set("Data", dataJSON).
Set("Progress", job.Progress).
Where(sq.Eq{"Id": job.Id, "Status": currentStatus}).
Suffix("RETURNING " + strings.Join(jss.jobColumns, ", "))
var jobs []*model.Job
if err := jss.GetMaster().SelectBuilder(&jobs, builder); err != nil {
return nil, errors.Wrapf(err, "failed to update Job with id=%s", job.Id)
}
if len(jobs) != 1 {
return nil, nil
}
return jobs[0], nil
}
func (jss SqlJobStore) UpdateStatus(id string, status string) (*model.Job, error) {
builder := jss.getQueryBuilder().
Update("Jobs").
Set("Status", status).
Set("LastActivityAt", model.GetMillis()).
Where(sq.Eq{"Id": id}).
Suffix("RETURNING " + strings.Join(jss.jobColumns, ", "))
var jobs []*model.Job
if err := jss.GetMaster().SelectBuilder(&jobs, builder); err != nil {
return nil, errors.Wrapf(err, "failed to update Job with id=%s", id)
}
if len(jobs) != 1 {
return nil, store.NewErrNotFound("Job", id)
}
return jobs[0], nil
}
func (jss SqlJobStore) UpdateStatusOptimistically(id string, currentStatus string, newStatus string) (*model.Job, error) {
lastActivityAndStartTime := model.GetMillis()
// Use RETURNING to get the updated job in a single query
builder := jss.getQueryBuilder().
Update("Jobs").
Set("LastActivityAt", lastActivityAndStartTime).
Set("Status", newStatus).
Where(sq.Eq{"Id": id, "Status": currentStatus}).
Suffix("RETURNING " + strings.Join(jss.jobColumns, ", "))
if newStatus == model.JobStatusInProgress {
builder = builder.Set("StartAt", lastActivityAndStartTime)
}
var job []*model.Job
if err := jss.GetMaster().SelectBuilder(&job, builder); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, store.NewErrNotFound("Job", id)
}
return nil, errors.Wrapf(err, "failed to update Job with id=%s", id)
}
// we are updating by id, so we should only ever update 1 job
if len(job) != 1 {
// no row was updated, but no error above, so to remain consistent we return nil, nil
return nil, nil
}
return job[0], nil
}
func (jss SqlJobStore) Get(rctx request.CTX, id string) (*model.Job, error) {
query, args, err := jss.jobQuery.
Where(sq.Eq{"Id": id}).ToSql()
if err != nil {
return nil, errors.Wrap(err, "job_tosql")
}
var status model.Job
if err = jss.GetReplica().Get(&status, query, args...); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, store.NewErrNotFound("Job", id)
}
return nil, errors.Wrapf(err, "failed to get Job with id=%s", id)
}
return &status, nil
}
func (jss SqlJobStore) GetAllByTypesPage(rctx request.CTX, jobTypes []string, page int, perPage int) ([]*model.Job, error) {
offset := page * perPage
query, args, err := jss.jobQuery.
Where(sq.Eq{"Type": jobTypes}).
OrderBy("CreateAt DESC").
Limit(uint64(perPage)).
Offset(uint64(offset)).ToSql()
if err != nil {
return nil, errors.Wrap(err, "job_tosql")
}
var jobs []*model.Job
if err = jss.GetReplica().Select(&jobs, query, args...); err != nil {
return nil, errors.Wrapf(err, "failed to find Jobs with types")
}
return jobs, nil
}
func (jss SqlJobStore) GetAllByType(rctx request.CTX, jobType string) ([]*model.Job, error) {
query, args, err := jss.jobQuery.
Where(sq.Eq{"Type": jobType}).
OrderBy("CreateAt DESC").ToSql()
if err != nil {
return nil, errors.Wrap(err, "job_tosql")
}
statuses := []*model.Job{}
if err = jss.GetReplica().Select(&statuses, query, args...); err != nil {
return nil, errors.Wrapf(err, "failed to find Jobs with type=%s", jobType)
}
return statuses, nil
}
func (jss SqlJobStore) GetAllByTypeAndStatus(rctx request.CTX, jobType string, status string) ([]*model.Job, error) {
query, args, err := jss.jobQuery.
Where(sq.Eq{"Type": jobType, "Status": status}).
OrderBy("CreateAt DESC").ToSql()
if err != nil {
return nil, errors.Wrap(err, "job_tosql")
}
jobs := []*model.Job{}
if err = jss.GetReplica().Select(&jobs, query, args...); err != nil {
return nil, errors.Wrapf(err, "failed to find Jobs with type=%s", jobType)
}
return jobs, nil
}
func (jss SqlJobStore) GetAllByTypePage(rctx request.CTX, jobType string, page int, perPage int) ([]*model.Job, error) {
offset := page * perPage
query, args, err := jss.jobQuery.
Where(sq.Eq{"Type": jobType}).
OrderBy("CreateAt DESC").
Limit(uint64(perPage)).
Offset(uint64(offset)).ToSql()
if err != nil {
return nil, errors.Wrap(err, "job_tosql")
}
statuses := []*model.Job{}
if err = jss.GetReplica().Select(&statuses, query, args...); err != nil {
return nil, errors.Wrapf(err, "failed to find Jobs with type=%s", jobType)
}
return statuses, nil
}
func (jss SqlJobStore) GetAllByStatus(rctx request.CTX, status string) ([]*model.Job, error) {
statuses := []*model.Job{}
query, args, err := jss.jobQuery.
Where(sq.Eq{"Status": status}).
OrderBy("CreateAt ASC").ToSql()
if err != nil {
return nil, errors.Wrap(err, "job_tosql")
}
if err = jss.GetReplica().Select(&statuses, query, args...); err != nil {
return nil, errors.Wrapf(err, "failed to find Jobs with status=%s", status)
}
return statuses, nil
}
func (jss SqlJobStore) GetAllByTypesAndStatusesPage(rctx request.CTX, jobType []string, status []string, offset int, limit int) ([]*model.Job, error) {
query, args, err := jss.jobQuery.
Where(sq.Eq{"Type": jobType, "Status": status}).
OrderBy("CreateAt DESC").
Limit(uint64(limit)).
Offset(uint64(offset)).ToSql()
if err != nil {
return nil, errors.Wrap(err, "job_tosql")
}
jobs := []*model.Job{}
if err = jss.GetReplica().Select(&jobs, query, args...); err != nil {
return nil, errors.Wrapf(err, "failed to find Jobs with types=%s and statuses=%s", strings.Join(jobType, ","), strings.Join(status, ","))
}
return jobs, nil
}
func (jss SqlJobStore) GetNewestJobByStatusAndType(status string, jobType string) (*model.Job, error) {
return jss.GetNewestJobByStatusesAndType([]string{status}, jobType)
}
func (jss SqlJobStore) GetNewestJobByStatusesAndType(status []string, jobType string) (*model.Job, error) {
query, args, err := jss.jobQuery.
Where(sq.Eq{"Status": status, "Type": jobType}).
OrderBy("CreateAt DESC").
Limit(1).ToSql()
if err != nil {
return nil, errors.Wrap(err, "job_tosql")
}
var job model.Job
if err = jss.GetReplica().Get(&job, query, args...); err != nil {
if err == sql.ErrNoRows {
return nil, store.NewErrNotFound("Job", fmt.Sprintf("<status, type>=<%s, %s>", strings.Join(status, ","), jobType))
}
return nil, errors.Wrapf(err, "failed to find Job with statuses=%s and type=%s", strings.Join(status, ","), jobType)
}
return &job, nil
}
func (jss SqlJobStore) GetCountByStatusAndType(status string, jobType string) (int64, error) {
query, args, err := jss.getQueryBuilder().
Select("COUNT(*)").
From("Jobs").
Where(sq.Eq{"Status": status, "Type": jobType}).ToSql()
if err != nil {
return 0, errors.Wrap(err, "job_tosql")
}
var count int64
err = jss.GetReplica().Get(&count, query, args...)
if err != nil {
return int64(0), errors.Wrapf(err, "failed to count Jobs with status=%s and type=%s", status, jobType)
}
return count, nil
}
func (jss SqlJobStore) jobTypeAndDataQuery(query sq.SelectBuilder, jobType string, data map[string]string, statuses ...string) sq.SelectBuilder {
query = query.Where(sq.Eq{"Type": jobType})
// Add status filtering if provided - enables full usage of idx_jobs_status_type index
if len(statuses) > 0 {
query = query.Where(sq.Eq{"Status": statuses})
}
// Add JSON data filtering for each key-value pair
for key, value := range data {
query = query.Where(sq.Expr("Data->? = ?", key, fmt.Sprintf(`"%s"`, value)))
}
return query
}
func (jss SqlJobStore) GetByTypeAndData(rctx request.CTX, jobType string, data map[string]string, useMaster bool, statuses ...string) ([]*model.Job, error) {
query := jss.jobTypeAndDataQuery(jss.jobQuery, jobType, data, statuses...)
queryString, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrap(err, "get_by_type_and_data_tosql")
}
var jobs []*model.Job
// For consistency-critical operations (like job deduplication), use master
db := jss.GetReplica()
if useMaster {
db = jss.GetMaster()
}
if err := db.Select(&jobs, queryString, args...); err != nil {
return nil, errors.Wrap(err, "failed to get Jobs by type and data")
}
return jobs, nil
}
func (jss SqlJobStore) Delete(id string) (string, error) {
query, args, err := jss.getQueryBuilder().
Delete("Jobs").
Where(sq.Eq{"Id": id}).ToSql()
if err != nil {
return "", errors.Wrap(err, "job_tosql")
}
if _, err = jss.GetMaster().Exec(query, args...); err != nil {
return "", errors.Wrapf(err, "failed to delete Job with id=%s", id)
}
return id, nil
}
func (jss SqlJobStore) Cleanup(expiryTime int64, batchSize int) error {
query := "DELETE FROM Jobs WHERE Id IN (SELECT Id FROM Jobs WHERE CreateAt < ? AND (Status != ? AND Status != ?) ORDER BY CreateAt ASC LIMIT ?)"
var rowsAffected int64 = 1
for rowsAffected > 0 {
sqlResult, err := jss.GetMaster().Exec(query,
expiryTime, model.JobStatusInProgress, model.JobStatusPending, batchSize)
if err != nil {
return errors.Wrap(err, "unable to delete jobs")
}
var rowErr error
rowsAffected, rowErr = sqlResult.RowsAffected()
if rowErr != nil {
return errors.Wrap(rowErr, "unable to delete jobs")
}
time.Sleep(jobsCleanupDelay)
}
return nil
}
// isRepeatableError is a bit of copied code from retrylayer.go.
// A little copying is fine because we don't want to import another package
// in the store layer
func isRepeatableError(err error) bool {
var pqErr *pq.Error
switch {
case errors.As(err, &pqErr):
if pqErr.Code == "40001" || pqErr.Code == "40P01" {
return true
}
}
return false
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestJobStore(t *testing.T) {
StoreTest(t, storetest.TestJobStore)
}

View File

@@ -0,0 +1,97 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
// SqlLicenseStore encapsulates the database writes and reads for
// model.LicenseRecord objects.
type SqlLicenseStore struct {
*SqlStore
}
func newSqlLicenseStore(sqlStore *SqlStore) store.LicenseStore {
return &SqlLicenseStore{sqlStore}
}
// Save validates and stores the license instance in the database. The Id
// and Bytes fields are mandatory. The Bytes field is limited to a maximum
// of 10000 bytes. Provided license is saved only if missing.
func (ls SqlLicenseStore) Save(license *model.LicenseRecord) error {
license.PreSave()
if err := license.IsValid(); err != nil {
return err
}
query := ls.getQueryBuilder().
Insert("Licenses").
Columns("Id", "CreateAt", "Bytes").
Values(license.Id, license.CreateAt, license.Bytes)
query = query.SuffixExpr(sq.Expr("ON CONFLICT (Id) DO NOTHING"))
queryString, args, err := query.ToSql()
if err != nil {
return errors.Wrap(err, "license_tosql")
}
if _, err := ls.GetMaster().Exec(queryString, args...); err != nil {
return errors.Wrapf(err, "failed to insert License with licenseId=%s", license.Id)
}
return nil
}
// Get obtains the license with the provided id parameter from the database.
// If the license doesn't exist it returns a model.AppError with
// http.StatusNotFound in the StatusCode field.
func (ls SqlLicenseStore) Get(rctx request.CTX, id string) (*model.LicenseRecord, error) {
query := ls.getQueryBuilder().
Select("Id, CreateAt, Bytes").
From("Licenses").
Where(sq.Eq{"Id": id})
queryString, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrap(err, "license_record_tosql")
}
license := &model.LicenseRecord{}
if err := ls.DBXFromContext(rctx.Context()).Get(license, queryString, args...); err != nil {
// Callers clear the active license on ErrNotFound, so a store failure must
// not be reported as an absent license.
if errors.Is(err, sql.ErrNoRows) {
return nil, store.NewErrNotFound("License", id)
}
return nil, errors.Wrapf(err, "failed to get License with id=%s", id)
}
return license, nil
}
func (ls SqlLicenseStore) GetAll() ([]*model.LicenseRecord, error) {
query := ls.getQueryBuilder().
Select("Id, CreateAt, Bytes").
From("Licenses")
queryString, _, err := query.ToSql()
if err != nil {
return nil, errors.Wrap(err, "license_tosql")
}
licenses := []*model.LicenseRecord{}
if err := ls.GetReplica().Select(&licenses, queryString); err != nil {
return nil, errors.Wrap(err, "failed to fetch licenses")
}
return licenses, nil
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestLicenseStore(t *testing.T) {
StoreTest(t, storetest.TestLicenseStore)
}

View File

@@ -0,0 +1,99 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"encoding/json"
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
type SqlLinkMetadataStore struct {
*SqlStore
linkMetadataColumns []string
linkMetadataQuery sq.SelectBuilder
}
func newSqlLinkMetadataStore(sqlStore *SqlStore) store.LinkMetadataStore {
s := &SqlLinkMetadataStore{
SqlStore: sqlStore,
linkMetadataColumns: []string{
"Hash",
"URL",
"Timestamp",
"Type",
"Data",
},
}
s.linkMetadataQuery = s.getQueryBuilder().
Select(s.linkMetadataColumns...).
From("LinkMetadata")
return s
}
func (s SqlLinkMetadataStore) Save(metadata *model.LinkMetadata) (*model.LinkMetadata, error) {
if err := metadata.IsValid(); err != nil {
return nil, err
}
metadata.PreSave()
metadataBytes, err := json.Marshal(metadata.Data)
if err != nil {
return nil, errors.Wrap(err, "could not serialize metadataBytes to JSON")
}
if s.IsBinaryParamEnabled() {
metadataBytes = AppendBinaryFlag(metadataBytes)
}
query := s.getQueryBuilder().
Insert("LinkMetadata").
Columns(s.linkMetadataColumns...).
Values(metadata.Hash, metadata.URL, metadata.Timestamp, metadata.Type, metadataBytes)
query = query.SuffixExpr(sq.Expr("ON CONFLICT (hash) DO UPDATE SET URL = ?, Timestamp = ?, Type = ?, Data = ?", metadata.URL, metadata.Timestamp, metadata.Type, metadataBytes))
q, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrap(err, "metadata_tosql")
}
_, err = s.GetMaster().Exec(q, args...)
if err != nil && !IsUniqueConstraintError(err, []string{"PRIMARY", "linkmetadata_pkey"}) {
return nil, errors.Wrap(err, "could not save link metadata")
}
return metadata, nil
}
func (s SqlLinkMetadataStore) Get(url string, timestamp int64) (*model.LinkMetadata, error) {
var metadata model.LinkMetadata
query, args, err := s.linkMetadataQuery.
Where(sq.Eq{"URL": url, "Timestamp": timestamp}).
ToSql()
if err != nil {
return nil, errors.Wrap(err, "could not create query with querybuilder")
}
err = s.GetReplica().Get(&metadata, query, args...)
if err != nil {
if err == sql.ErrNoRows {
return nil, store.NewErrNotFound("LinkMetadata", "url="+url)
}
return nil, errors.Wrapf(err, "could not get metadata with selectone: url=%s", url)
}
err = metadata.DeserializeDataToConcreteType()
if err != nil {
return nil, errors.Wrapf(err, "could not deserialize metadata to concrete type for url=%s", url)
}
return &metadata, nil
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestLinkMetadataStore(t *testing.T) {
StoreTest(t, storetest.TestLinkMetadataStore)
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore_test
import (
"flag"
"os"
"strconv"
"testing"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/store/sqlstore"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
)
var mainHelper *testlib.MainHelper
func TestMain(m *testing.M) {
flag.Parse()
if f := flag.Lookup("test.list"); f != nil && f.Value.String() != "" {
os.Exit(m.Run())
}
var parallelism int
if f := flag.Lookup("test.parallel"); f != nil {
parallelism, _ = strconv.Atoi(f.Value.String())
}
runParallel := os.Getenv("ENABLE_FULLY_PARALLEL_TESTS") == "true" && parallelism > 1
if runParallel {
mlog.Info("Fully parallel tests enabled", mlog.Int("parallelism", parallelism))
} else {
parallelism = 1
}
mainHelper = testlib.NewMainHelperWithOptions(nil)
defer mainHelper.Close()
sqlstore.InitTest(mainHelper.Logger, parallelism)
mainHelper.Main(m)
sqlstore.TearDownTest()
}

View File

@@ -0,0 +1,221 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"cmp"
"context"
"fmt"
"io"
"log"
"path"
"slices"
"strconv"
"sync"
"github.com/mattermost/morph"
ps "github.com/mattermost/morph/drivers/postgres"
"github.com/mattermost/morph/models"
mbindata "github.com/mattermost/morph/sources/embedded"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/db"
)
type Migrator struct {
engine *morph.Morph
store *SqlStore
}
func NewMigrator(settings model.SqlSettings, logger mlog.LoggerIFace, dryRun bool) (*Migrator, error) {
ss := &SqlStore{
rrCounter: 0,
srCounter: 0,
settings: &settings,
logger: logger,
quitMonitor: make(chan struct{}),
wgMonitor: &sync.WaitGroup{},
}
err := ss.initConnection()
if err != nil {
return nil, fmt.Errorf("error in initializing connection: %w", err)
}
ver, err := ss.GetDbVersion(true)
if err != nil {
return nil, fmt.Errorf("error while getting DB version: %w", err)
}
if err = ss.checkVersion(ver); err != nil {
return nil, fmt.Errorf("error while checking DB version: %w", err)
}
engine, err := ss.initMorph(dryRun, true)
if err != nil {
return nil, fmt.Errorf("failed to initialize morph: %w", err)
}
return &Migrator{
engine: engine,
store: ss,
}, nil
}
func (m *Migrator) Close() error {
if err := m.engine.Close(); err != nil {
return fmt.Errorf("failed to close morph engine: %w", err)
}
m.store.Close()
return nil
}
func (m *Migrator) GetFileName(plan *models.Plan) (string, error) {
if len(plan.Migrations) == 0 {
return "", fmt.Errorf("plan is empty")
}
to := plan.Migrations[len(plan.Migrations)-1].Version
from, err := m.store.GetDBSchemaVersion()
if err != nil {
return "", err
}
return fmt.Sprintf("migration_plan_%d_%d", from, to), nil
}
func (ss *SqlStore) initMorph(dryRun, enableLogging bool) (*morph.Morph, error) {
assets := db.Assets()
assetsList, err := assets.ReadDir(path.Join("migrations", ss.DriverName()))
if err != nil {
return nil, err
}
assetNamesForDriver := make([]string, len(assetsList))
for i, entry := range assetsList {
assetNamesForDriver[i] = entry.Name()
}
src, err := mbindata.WithInstance(&mbindata.AssetSource{
Names: assetNamesForDriver,
AssetFunc: func(name string) ([]byte, error) {
return assets.ReadFile(path.Join("migrations", ss.DriverName(), name))
},
})
if err != nil {
return nil, err
}
driver, err := ps.WithInstance(ss.GetMaster().DB().DB)
if err != nil {
return nil, err
}
var logWriter io.Writer
if enableLogging {
logWriter = &morphWriter{}
} else {
logWriter = io.Discard
}
opts := []morph.EngineOption{
morph.WithLogger(log.New(logWriter, "", log.Lshortfile)),
morph.WithLock("mm-lock-key"),
morph.SetStatementTimeoutInSeconds(*ss.settings.MigrationsStatementTimeoutSeconds),
morph.SetDryRun(dryRun),
}
engine, err := morph.New(context.Background(), driver, src, opts...)
if err != nil {
return nil, err
}
return engine, nil
}
func (ss *SqlStore) migrate(direction migrationDirection, dryRun, enableMorphLogging bool) error {
engine, err := ss.initMorph(dryRun, enableMorphLogging)
if err != nil {
return err
}
defer engine.Close()
switch direction {
case migrationsDirectionDown:
_, err = engine.ApplyDown(-1)
return err
default:
return engine.ApplyAll()
}
}
func (m *Migrator) GeneratePlan(shouldRecover bool) (*models.Plan, error) {
diff, err := m.engine.Diff(models.Up)
if err != nil {
return nil, err
}
plan, err := m.engine.GeneratePlan(diff, shouldRecover)
if err != nil {
return nil, err
}
return plan, nil
}
// MigrateWithPlan migrates the database to the latest version using the provided plan.
func (m *Migrator) MigrateWithPlan(plan *models.Plan, dryRun bool) error {
return m.engine.ApplyPlan(plan)
}
// PreMigrate runs the pre-migration handlers that normally execute during
// server startup. Callers on the CLI up-migration path (e.g. `mattermost db
// migrate`, used by cloud upgrades) must invoke this before MigrateWithPlan so
// the same fixes apply outside of server startup. Skipped under dryRun because
// preMigration writes directly via GetMaster().Exec and does not participate
// in Morph's dry-run.
// This is intentionally only called for forward migrations and skipped for
// downgrades.
func (m *Migrator) PreMigrate() error {
return m.store.preMigration()
}
func (m *Migrator) DowngradeMigrations(dryRun bool, versions ...string) error {
migrations, err := m.engine.Diff(models.Down)
if err != nil {
return err
}
migrationsToDowngrade := make([]*models.Migration, 0, len(versions))
for _, version := range versions {
for _, migration := range migrations {
versionNumber, sErr := strconv.Atoi(version)
if sErr != nil {
return sErr
}
if migration.Version == uint32(versionNumber) {
migrationsToDowngrade = append(migrationsToDowngrade, migration)
}
}
}
slices.SortFunc(migrationsToDowngrade, func(a, b *models.Migration) int {
return cmp.Compare(b.Version, a.Version)
})
if len(migrationsToDowngrade) != len(versions) {
mlog.Warn("could not match give migration versions, going to downgrade only the migrations those are available.")
}
plan, err := m.engine.GeneratePlan(migrationsToDowngrade, false)
if err != nil {
return err
}
return m.engine.ApplyPlan(plan)
}

View File

@@ -0,0 +1,152 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
"github.com/mattermost/morph/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const postgresSchemaObjectsQuery = `
SELECT pg_catalog.pg_describe_object(d.classid, d.objid, d.objsubid)
FROM pg_catalog.pg_depend d
WHERE d.refclassid = 'pg_catalog.pg_namespace'::pg_catalog.regclass
AND d.refobjid = pg_catalog.current_schema()::pg_catalog.regnamespace
AND d.deptype = 'n'
ORDER BY 1`
// postgresSchemaObjects returns stable descriptions of objects that belong to
// the current PostgreSQL schema. Use it to list all tables, indexes, and other
// schema objects in the database.
func postgresSchemaObjects(t *testing.T, store *SqlStore) []string {
t.Helper()
var objects []string
require.NoError(t, store.GetMaster().Select(&objects, postgresSchemaObjectsQuery))
return objects
}
func TestUpAndDownMigrations(t *testing.T) {
logger := mlog.CreateTestLogger(t)
t.Run("store", func(t *testing.T) {
settings, err := makeSqlSettings(model.DatabaseDriverPostgres)
if err != nil {
t.Skip(err)
}
t.Cleanup(func() {
storetest.CleanupSqlSettings(settings)
})
store, err := New(*settings, logger, nil)
require.NoError(t, err)
t.Cleanup(func() {
store.Close()
})
err = store.migrate(migrationsDirectionDown, false, true)
assert.NoError(t, err, "downing migrations should not error")
})
t.Run("manual morph application", func(t *testing.T) {
settings, err := makeSqlSettings(model.DatabaseDriverPostgres)
if err != nil {
t.Skip(err)
}
t.Cleanup(func() {
storetest.CleanupSqlSettings(settings)
})
store, err := New(*settings, logger, nil, SkipMigrations())
require.NoError(t, err)
t.Cleanup(func() {
store.Close()
})
engine, err := store.initMorph(false, true)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, engine.Close())
})
pending, err := engine.Diff(models.Up)
require.NoError(t, err)
require.NotEmpty(t, pending)
initialSchema := postgresSchemaObjects(t, store)
applied, err := engine.Apply(-1)
require.NoError(t, err, "applying migrations should not error")
require.Equal(t, len(pending), applied)
rolledBack, err := engine.ApplyDown(-1)
require.NoError(t, err, "downing migrations should not error")
require.Equal(t, applied, rolledBack)
var migrationCount int
require.NoError(t, store.GetMaster().Get(&migrationCount, "SELECT COUNT(*) FROM db_migrations"))
assert.Zero(t, migrationCount, "all migration records should be removed")
assert.Equal(t, initialSchema, postgresSchemaObjects(t, store),
"schema differs after rollback; a down migration may not fully reverse its corresponding up migration")
})
}
// TestMigratorPreMigrate exercises the exported entry point invoked by the
// `mattermost db migrate` CLI. The handler exists so cloud upgrades, which
// bypass sqlstore.New(), still apply the pre-migration fixes that ship with
// each release.
func TestMigratorPreMigrate(t *testing.T) {
if enableFullyParallelTests {
t.Parallel()
}
logger := mlog.CreateConsoleTestLogger(t)
const markerName = "renumber_roles_schemeid_migrations"
t.Run("runs pre-migrations and marks them complete", func(t *testing.T) {
settings, err := makeSqlSettings(model.DatabaseDriverPostgres)
if err != nil {
t.Skip(err)
}
store, err := New(*settings, logger, nil)
require.NoError(t, err)
defer store.Close()
_, err = store.GetMaster().Exec("DELETE FROM Systems WHERE Name = $1", markerName)
require.NoError(t, err)
migrator, err := NewMigrator(*settings, logger, false)
require.NoError(t, err)
defer migrator.Close()
require.NoError(t, migrator.PreMigrate())
done, err := store.isPreMigrationComplete(markerName)
require.NoError(t, err)
assert.True(t, done, "PreMigrate should set the completion marker on a non-dry-run")
})
t.Run("idempotent across repeated calls", func(t *testing.T) {
settings, err := makeSqlSettings(model.DatabaseDriverPostgres)
if err != nil {
t.Skip(err)
}
store, err := New(*settings, logger, nil)
require.NoError(t, err)
defer store.Close()
migrator, err := NewMigrator(*settings, logger, false)
require.NoError(t, err)
defer migrator.Close()
require.NoError(t, migrator.PreMigrate())
require.NoError(t, migrator.PreMigrate(), "second invocation must be a safe no-op")
})
}

View File

@@ -0,0 +1,336 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/db"
)
func readMigrationSQL(t *testing.T, filename string) string {
t.Helper()
data, err := db.Assets().ReadFile("migrations/postgres/" + filename)
require.NoError(t, err, "failed to read migration file %s", filename)
return string(data)
}
func TestMigration000185(t *testing.T) {
logger := mlog.CreateTestLogger(t)
settings, err := makeSqlSettings(model.DatabaseDriverPostgres)
if err != nil {
t.Skip(err)
}
store, err := New(*settings, logger, nil)
require.NoError(t, err)
defer store.Close()
master := store.GetMaster()
upSQL := readMigrationSQL(t, "000176_migrate_cpa_to_access_control.up.sql")
downSQL := readMigrationSQL(t, "000176_migrate_cpa_to_access_control.down.sql")
// Insert a group simulating pre-migration CPA state.
groupID := model.NewId()
_, err = master.Exec("INSERT INTO PropertyGroups (ID, Name) VALUES (?, ?)", groupID, "custom_profile_attributes")
require.NoError(t, err)
t.Cleanup(func() {
master.Exec("DELETE FROM PropertyValues WHERE GroupID = ?", groupID) //nolint:errcheck
master.Exec("DELETE FROM PropertyFields WHERE GroupID = ?", groupID) //nolint:errcheck
master.Exec("DELETE FROM PropertyGroups WHERE ID = ?", groupID) //nolint:errcheck
})
now := model.GetMillis()
// Insert active fields with old format (no ObjectType, no permissions).
// fieldID1 and fieldID2 are non-managed; fieldID3 is admin-managed.
fieldID1 := model.NewId()
fieldID2 := model.NewId()
fieldID3 := model.NewId()
for _, f := range []struct {
id string
name string
ftype string
attrs string
}{
{fieldID1, "Text Field", "text", `{"visibility":"always","sort_order":1}`},
{fieldID2, "Select Field", "select", `{"options":[{"id":"opt1","name":"Option 1"}]}`},
{fieldID3, "Admin Managed Field", "text", `{"visibility":"always","sort_order":3,"managed":"admin"}`},
} {
_, err = master.Exec(
`INSERT INTO PropertyFields
(ID, GroupID, Name, Type, Attrs, TargetID, TargetType, ObjectType, CreateAt, UpdateAt, DeleteAt, Protected)
VALUES (?, ?, ?, ?, ?::jsonb, '', '', '', ?, ?, 0, false)`,
f.id, groupID, f.name, f.ftype, f.attrs, now, now,
)
require.NoError(t, err, "inserting field %s", f.name)
}
// Insert a soft-deleted field to verify all fields are migrated.
deletedFieldID := model.NewId()
_, err = master.Exec(
`INSERT INTO PropertyFields
(ID, GroupID, Name, Type, Attrs, TargetID, TargetType, ObjectType, CreateAt, UpdateAt, DeleteAt, Protected)
VALUES (?, ?, 'Deleted Field', 'text', '{}'::jsonb, '', '', '', ?, ?, ?, false)`,
deletedFieldID, groupID, now, now, now,
)
require.NoError(t, err)
// Insert a property value.
valueID := model.NewId()
targetUserID := model.NewId()
_, err = master.Exec(
`INSERT INTO PropertyValues
(ID, TargetID, TargetType, GroupID, FieldID, Value, CreateAt, UpdateAt, DeleteAt)
VALUES (?, ?, 'user', ?, ?, '"hello"'::jsonb, ?, ?, 0)`,
valueID, targetUserID, groupID, fieldID1, now, now,
)
require.NoError(t, err)
// ---- Run UP migration ----
_, err = master.ExecNoTimeout(upSQL)
require.NoError(t, err, "up migration should succeed")
// Verify: group renamed.
var groupName string
require.NoError(t, master.Get(&groupName, "SELECT Name FROM PropertyGroups WHERE ID = ?", groupID))
assert.Equal(t, "access_control", groupName)
// Verify: all fields (including soft-deleted) have new metadata.
// Non-managed fields get PermissionValues = 'member'.
// Admin-managed fields get PermissionValues = 'sysadmin'.
for _, tc := range []struct {
id string
label string
expectedPermissionValues string
}{
{fieldID1, "non-managed text field", "member"},
{fieldID2, "non-managed select field", "member"},
{fieldID3, "admin-managed field", "sysadmin"},
{deletedFieldID, "soft-deleted non-managed field", "member"},
} {
var f struct {
ObjectType string `db:"objecttype"`
TargetType string `db:"targettype"`
PermissionField sql.NullString `db:"permissionfield"`
PermissionValues sql.NullString `db:"permissionvalues"`
PermissionOptions sql.NullString `db:"permissionoptions"`
}
require.NoError(t, master.Get(&f, "SELECT ObjectType, TargetType, PermissionField, PermissionValues, PermissionOptions FROM PropertyFields WHERE ID = ?", tc.id))
assert.Equal(t, "user", f.ObjectType, "%s ObjectType", tc.label)
assert.Equal(t, "system", f.TargetType, "%s TargetType", tc.label)
assert.True(t, f.PermissionField.Valid, "%s PermissionField should be set", tc.label)
assert.Equal(t, "sysadmin", f.PermissionField.String, "%s PermissionField", tc.label)
assert.True(t, f.PermissionValues.Valid, "%s PermissionValues should be set", tc.label)
assert.Equal(t, tc.expectedPermissionValues, f.PermissionValues.String, "%s PermissionValues", tc.label)
assert.True(t, f.PermissionOptions.Valid, "%s PermissionOptions should be set", tc.label)
assert.Equal(t, "sysadmin", f.PermissionOptions.String, "%s PermissionOptions", tc.label)
}
// Verify: property value is unchanged (GroupID still references the same ID).
var val struct {
GroupID string `db:"groupid"`
TargetID string `db:"targetid"`
TargetType string `db:"targettype"`
}
require.NoError(t, master.Get(&val, "SELECT GroupID, TargetID, TargetType FROM PropertyValues WHERE ID = ?", valueID))
assert.Equal(t, groupID, val.GroupID, "value GroupID should be unchanged")
assert.Equal(t, targetUserID, val.TargetID, "value TargetID should be unchanged")
assert.Equal(t, "user", val.TargetType, "value TargetType should be unchanged")
// Verify: the user attribute matview exists and includes the ObjectType
// filter (user-type fields only). Since migration 000216 the single
// AttributeView is split per object type; the user row lives in UserAttributeView.
var viewDef string
err = master.Get(&viewDef, "SELECT definition FROM pg_matviews WHERE matviewname = 'userattributeview'")
require.NoError(t, err, "UserAttributeView should exist")
assert.Contains(t, viewDef, "pf.objecttype", "view definition should filter by pf.ObjectType")
// Verify: materialized view contains expected data after refresh.
_, err = master.ExecNoTimeout("REFRESH MATERIALIZED VIEW UserAttributeView")
require.NoError(t, err, "refreshing UserAttributeView should succeed")
var viewRow struct {
GroupID string `db:"groupid"`
TargetID string `db:"targetid"`
TargetType string `db:"targettype"`
Attributes []byte `db:"attributes"`
}
err = master.Get(&viewRow, "SELECT GroupID, TargetID, TargetType, Attributes FROM UserAttributeView WHERE TargetID = ?", targetUserID)
require.NoError(t, err, "UserAttributeView should contain a row for the target user")
assert.Equal(t, groupID, viewRow.GroupID)
assert.Equal(t, targetUserID, viewRow.TargetID)
assert.Equal(t, "user", viewRow.TargetType)
// The text field value "hello" should appear under the field name "Text Field".
var attrs map[string]json.RawMessage
require.NoError(t, json.Unmarshal(viewRow.Attributes, &attrs))
assert.JSONEq(t, `"hello"`, string(attrs["Text Field"]), "text field value should be materialized")
// ---- Run DOWN migration ----
_, err = master.ExecNoTimeout(downSQL)
require.NoError(t, err, "down migration should succeed")
// Verify: group name reverted.
require.NoError(t, master.Get(&groupName, "SELECT Name FROM PropertyGroups WHERE ID = ?", groupID))
assert.Equal(t, "custom_profile_attributes", groupName)
// Verify: fields reverted.
for _, fid := range []string{fieldID1, fieldID2, fieldID3, deletedFieldID} {
var f struct {
ObjectType string `db:"objecttype"`
TargetType string `db:"targettype"`
PermissionField sql.NullString `db:"permissionfield"`
PermissionValues sql.NullString `db:"permissionvalues"`
PermissionOptions sql.NullString `db:"permissionoptions"`
}
require.NoError(t, master.Get(&f, "SELECT ObjectType, TargetType, PermissionField, PermissionValues, PermissionOptions FROM PropertyFields WHERE ID = ?", fid))
assert.Equal(t, "", f.ObjectType, "field %s ObjectType should revert", fid)
assert.Equal(t, "", f.TargetType, "field %s TargetType should revert", fid)
assert.False(t, f.PermissionField.Valid, "field %s PermissionField should be NULL", fid)
assert.False(t, f.PermissionValues.Valid, "field %s PermissionValues should be NULL", fid)
assert.False(t, f.PermissionOptions.Valid, "field %s PermissionOptions should be NULL", fid)
}
// Verify: value still unchanged after down migration.
require.NoError(t, master.Get(&val, "SELECT GroupID, TargetID, TargetType FROM PropertyValues WHERE ID = ?", valueID))
assert.Equal(t, groupID, val.GroupID, "value GroupID should remain unchanged after down")
}
func TestMigration000185DownPreservesNonUserFields(t *testing.T) {
logger := mlog.CreateTestLogger(t)
settings, err := makeSqlSettings(model.DatabaseDriverPostgres)
if err != nil {
t.Skip(err)
}
store, err := New(*settings, logger, nil)
require.NoError(t, err)
defer store.Close()
master := store.GetMaster()
upSQL := readMigrationSQL(t, "000176_migrate_cpa_to_access_control.up.sql")
downSQL := readMigrationSQL(t, "000176_migrate_cpa_to_access_control.down.sql")
groupID := model.NewId()
_, err = master.Exec("INSERT INTO PropertyGroups (ID, Name) VALUES (?, ?)", groupID, "custom_profile_attributes")
require.NoError(t, err)
t.Cleanup(func() {
master.Exec("DELETE FROM PropertyFields WHERE GroupID = ?", groupID) //nolint:errcheck
master.Exec("DELETE FROM PropertyGroups WHERE ID = ?", groupID) //nolint:errcheck
})
now := model.GetMillis()
// Insert a legacy user field that the up migration will touch.
userFieldID := model.NewId()
_, err = master.Exec(
`INSERT INTO PropertyFields
(ID, GroupID, Name, Type, Attrs, TargetID, TargetType, ObjectType, CreateAt, UpdateAt, DeleteAt, Protected)
VALUES (?, ?, 'Legacy User Field', 'text', '{}'::jsonb, '', '', '', ?, ?, 0, false)`,
userFieldID, groupID, now, now,
)
require.NoError(t, err)
// Run UP migration — legacy user field gets ObjectType='user', TargetType='system'.
_, err = master.ExecNoTimeout(upSQL)
require.NoError(t, err, "up migration should succeed")
// Simulate a post-migration channel-scoped field created via the
// generic property API against the (now renamed) access_control
// group.
channelFieldID := model.NewId()
channelTargetID := model.NewId()
_, err = master.Exec(
`INSERT INTO PropertyFields
(ID, GroupID, Name, Type, Attrs, TargetID, TargetType, ObjectType, PermissionField, PermissionValues, PermissionOptions, CreateAt, UpdateAt, DeleteAt, Protected)
VALUES (?, ?, 'Channel Classification', 'select', '{}'::jsonb, ?, 'channel', 'channel', 'sysadmin', 'member', 'sysadmin', ?, ?, 0, false)`,
channelFieldID, groupID, channelTargetID, now, now,
)
require.NoError(t, err)
// Run DOWN migration — must revert only user/system fields, not the channel one.
_, err = master.ExecNoTimeout(downSQL)
require.NoError(t, err, "down migration should succeed")
// The original user field reverts to legacy metadata.
var userField struct {
ObjectType string `db:"objecttype"`
TargetType string `db:"targettype"`
PermissionField sql.NullString `db:"permissionfield"`
PermissionValues sql.NullString `db:"permissionvalues"`
PermissionOptions sql.NullString `db:"permissionoptions"`
}
require.NoError(t, master.Get(&userField, "SELECT ObjectType, TargetType, PermissionField, PermissionValues, PermissionOptions FROM PropertyFields WHERE ID = ?", userFieldID))
assert.Equal(t, "", userField.ObjectType, "user field ObjectType should revert")
assert.Equal(t, "", userField.TargetType, "user field TargetType should revert")
assert.False(t, userField.PermissionField.Valid, "user field PermissionField should be NULL")
// The post-migration channel field keeps its PSAv2 metadata intact.
var channelField struct {
ObjectType string `db:"objecttype"`
TargetType string `db:"targettype"`
TargetID string `db:"targetid"`
PermissionField sql.NullString `db:"permissionfield"`
PermissionValues sql.NullString `db:"permissionvalues"`
PermissionOptions sql.NullString `db:"permissionoptions"`
}
require.NoError(t, master.Get(&channelField, "SELECT ObjectType, TargetType, TargetID, PermissionField, PermissionValues, PermissionOptions FROM PropertyFields WHERE ID = ?", channelFieldID))
assert.Equal(t, "channel", channelField.ObjectType, "channel field ObjectType must survive rollback")
assert.Equal(t, "channel", channelField.TargetType, "channel field TargetType must survive rollback")
assert.Equal(t, channelTargetID, channelField.TargetID, "channel field TargetID must survive rollback")
assert.True(t, channelField.PermissionField.Valid, "channel field PermissionField must survive rollback")
assert.Equal(t, "sysadmin", channelField.PermissionField.String)
assert.True(t, channelField.PermissionValues.Valid)
assert.Equal(t, "member", channelField.PermissionValues.String)
assert.True(t, channelField.PermissionOptions.Valid)
assert.Equal(t, "sysadmin", channelField.PermissionOptions.String)
}
func TestMigration000185NoOpOnFreshDB(t *testing.T) {
logger := mlog.CreateTestLogger(t)
settings, err := makeSqlSettings(model.DatabaseDriverPostgres)
if err != nil {
t.Skip(err)
}
store, err := New(*settings, logger, nil)
require.NoError(t, err)
defer store.Close()
master := store.GetMaster()
upSQL := readMigrationSQL(t, "000176_migrate_cpa_to_access_control.up.sql")
downSQL := readMigrationSQL(t, "000176_migrate_cpa_to_access_control.down.sql")
// On a fresh database with no CPA group, both up and down should be
// safe no-ops (the UPDATE statements match zero rows).
_, err = master.ExecNoTimeout(upSQL)
assert.NoError(t, err, "up migration should be a safe no-op on fresh DB")
// The attribute matviews exist in the final schema. Since migration 000216
// the single AttributeView is split into per-object-type views.
for _, view := range []string{"userattributeview", "channelattributeview"} {
var viewExists bool
require.NoError(t, master.Get(&viewExists, "SELECT EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = $1)", view))
assert.True(t, viewExists, "%s should exist after up migration on fresh DB", view)
}
_, err = master.ExecNoTimeout(downSQL)
assert.NoError(t, err, "down migration should be a safe no-op on fresh DB")
}

View File

@@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func TestMigration000194(t *testing.T) {
logger := mlog.CreateTestLogger(t)
settings, err := makeSqlSettings(model.DatabaseDriverPostgres)
if err != nil {
t.Skip(err)
}
store, err := New(*settings, logger, nil)
require.NoError(t, err)
defer store.Close()
master := store.GetMaster()
upSQL := readMigrationSQL(t, "000194_add_type_id_index_to_access_control_policies.up.sql")
downSQL := readMigrationSQL(t, "000194_add_type_id_index_to_access_control_policies.down.sql")
indexExists := func() bool {
var exists bool
require.NoError(t, master.Get(&exists, "SELECT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_access_control_policies_type_id')"))
return exists
}
// CONCURRENTLY cannot run inside a transaction; ExecNoTimeout runs unwrapped.
_, err = master.ExecNoTimeout(upSQL)
require.NoError(t, err, "up migration should succeed")
assert.True(t, indexExists(), "index should exist after up migration")
// IF NOT EXISTS makes a second up a safe no-op.
_, err = master.ExecNoTimeout(upSQL)
require.NoError(t, err, "up migration should be idempotent")
assert.True(t, indexExists())
_, err = master.ExecNoTimeout(downSQL)
require.NoError(t, err, "down migration should succeed")
assert.False(t, indexExists(), "index should be gone after down migration")
// IF EXISTS makes a second down a safe no-op.
_, err = master.ExecNoTimeout(downSQL)
require.NoError(t, err, "down migration should be idempotent")
}

View File

@@ -0,0 +1,171 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
// TestMigration000198 exercises the select→rank conversion for the built-in
// classification fields: the type flip, the position-derived (1-based) rank
// backfill, the empty/absent-options guards, and the down round-trip. It also
// verifies that fields which merely share a name — wrong object type, wrong
// group, or an unrelated name — are left untouched, since the migration matches
// each (Name, ObjectType) pair explicitly within the access_control group.
func TestMigration000198(t *testing.T) {
logger := mlog.CreateTestLogger(t)
settings, err := makeSqlSettings(model.DatabaseDriverPostgres)
if err != nil {
t.Skip(err)
}
store, err := New(*settings, logger, nil)
require.NoError(t, err)
defer store.Close()
master := store.GetMaster()
upSQL := readMigrationSQL(t, "000198_convert_classification_fields_to_rank.up.sql")
downSQL := readMigrationSQL(t, "000198_convert_classification_fields_to_rank.down.sql")
// The migration resolves the group by name, so the field rows must hang off
// an 'access_control' group.
groupID := model.NewId()
_, err = master.Exec("INSERT INTO PropertyGroups (ID, Name) VALUES (?, ?)", groupID, "access_control")
require.NoError(t, err)
// A second, unrelated group used to prove the migration is group-scoped.
otherGroupID := model.NewId()
_, err = master.Exec("INSERT INTO PropertyGroups (ID, Name) VALUES (?, ?)", otherGroupID, "other_group_"+model.NewId())
require.NoError(t, err)
t.Cleanup(func() {
master.Exec("DELETE FROM PropertyFields WHERE GroupID IN (?, ?)", groupID, otherGroupID) //nolint:errcheck
master.Exec("DELETE FROM PropertyGroups WHERE ID IN (?, ?)", groupID, otherGroupID) //nolint:errcheck
})
now := model.GetMillis()
insertField := func(id, grpID, name, objectType, attrs string) {
t.Helper()
_, insErr := master.Exec(
`INSERT INTO PropertyFields
(ID, GroupID, Name, Type, Attrs, TargetID, TargetType, ObjectType, CreateAt, UpdateAt, DeleteAt, Protected)
VALUES (?, ?, ?, 'select', ?::jsonb, '', '', ?, ?, ?, 0, false)`,
id, grpID, name, attrs, objectType, now, now,
)
require.NoError(t, insErr, "inserting field %s", name)
}
type option struct {
ID string `json:"id"`
Name string `json:"name"`
Rank *int `json:"rank"`
}
readField := func(id string) (string, []option) {
t.Helper()
var row struct {
Type string `db:"type"`
Attrs []byte `db:"attrs"`
}
require.NoError(t, master.Get(&row, "SELECT Type, Attrs FROM PropertyFields WHERE ID = ?", id))
var parsed struct {
Options []option `json:"options"`
}
require.NoError(t, json.Unmarshal(row.Attrs, &parsed))
return row.Type, parsed.Options
}
var (
fieldTemplate = model.NewId() // classification/template: full happy path
fieldSystemEmpty = model.NewId() // system_classification/system: options=[] guard
fieldChannelNone = model.NewId() // channel_classification/channel: no options key
fieldWrongName = model.NewId() // access_control group, but unrelated name
fieldWrongObjType = model.NewId() // right name, wrong object type
fieldOtherGroup = model.NewId() // right name+object type, wrong group
)
insertField(fieldTemplate, groupID, "classification", "template",
`{"options":[{"id":"o1","name":"Public"},{"id":"o2","name":"Confidential"},{"id":"o3","name":"Secret"}]}`)
insertField(fieldSystemEmpty, groupID, "system_classification", "system", `{"options":[]}`)
insertField(fieldChannelNone, groupID, "channel_classification", "channel", `{"sort_order":7}`)
insertField(fieldWrongName, groupID, "not_classification", "template",
`{"options":[{"id":"x1","name":"A"}]}`)
insertField(fieldWrongObjType, groupID, "classification", "channel",
`{"options":[{"id":"y1","name":"B"}]}`)
insertField(fieldOtherGroup, otherGroupID, "classification", "template",
`{"options":[{"id":"z1","name":"C"}]}`)
// ---- UP ----
_, err = master.ExecNoTimeout(upSQL)
require.NoError(t, err, "up migration should succeed")
t.Run("up converts the template field and backfills 1-based ranks in order", func(t *testing.T) {
ftype, opts := readField(fieldTemplate)
assert.Equal(t, "rank", ftype)
require.Len(t, opts, 3)
assert.Equal(t, "Public", opts[0].Name)
assert.Equal(t, "Confidential", opts[1].Name)
assert.Equal(t, "Secret", opts[2].Name)
require.NotNil(t, opts[0].Rank)
require.NotNil(t, opts[1].Rank)
require.NotNil(t, opts[2].Rank)
assert.Equal(t, 1, *opts[0].Rank)
assert.Equal(t, 2, *opts[1].Rank)
assert.Equal(t, 3, *opts[2].Rank)
})
t.Run("up flips an empty-options field without fabricating options", func(t *testing.T) {
ftype, opts := readField(fieldSystemEmpty)
assert.Equal(t, "rank", ftype)
assert.Empty(t, opts)
})
t.Run("up flips an options-less field without adding an options array", func(t *testing.T) {
ftype, opts := readField(fieldChannelNone)
assert.Equal(t, "rank", ftype)
assert.Empty(t, opts)
})
t.Run("up leaves name/object-type/group mismatches as select", func(t *testing.T) {
for _, id := range []string{fieldWrongName, fieldWrongObjType, fieldOtherGroup} {
ftype, _ := readField(id)
assert.Equal(t, "select", ftype)
}
})
// ---- DOWN ----
_, err = master.ExecNoTimeout(downSQL)
require.NoError(t, err, "down migration should succeed")
t.Run("down reverts the template field to select and strips the ranks", func(t *testing.T) {
ftype, opts := readField(fieldTemplate)
assert.Equal(t, "select", ftype)
require.Len(t, opts, 3)
assert.Equal(t, "Public", opts[0].Name)
assert.Equal(t, "Confidential", opts[1].Name)
assert.Equal(t, "Secret", opts[2].Name)
assert.Nil(t, opts[0].Rank)
assert.Nil(t, opts[1].Rank)
assert.Nil(t, opts[2].Rank)
})
t.Run("down reverts the guarded fields to select", func(t *testing.T) {
ftype, opts := readField(fieldSystemEmpty)
assert.Equal(t, "select", ftype)
assert.Empty(t, opts)
ftype, opts = readField(fieldChannelNone)
assert.Equal(t, "select", ftype)
assert.Empty(t, opts)
})
}

View File

@@ -0,0 +1,116 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func matviewExists(t *testing.T, s *SqlStore, name string) bool {
t.Helper()
var count int
err := s.GetMaster().Get(&count,
"SELECT COUNT(*) FROM pg_matviews WHERE lower(matviewname) = lower($1)", name)
require.NoError(t, err)
return count > 0
}
// TestMigration000216 verifies the split of AttributeView into per-object-type
// views: after the migration both UserAttributeView and ChannelAttributeView
// exist (and the combined AttributeView is gone), each filtering to its own
// ObjectType. The down migration restores the single combined view.
func TestMigration000216(t *testing.T) {
logger := mlog.CreateTestLogger(t)
settings, err := makeSqlSettings(model.DatabaseDriverPostgres)
if err != nil {
t.Skip(err)
}
store, err := New(*settings, logger, nil)
require.NoError(t, err)
defer store.Close()
// New() applies all migrations, so 000216 is already in effect.
require.True(t, matviewExists(t, store, "UserAttributeView"), "UserAttributeView should exist after migration")
require.True(t, matviewExists(t, store, "ChannelAttributeView"), "ChannelAttributeView should exist after migration")
require.False(t, matviewExists(t, store, "AttributeView"), "combined AttributeView should be gone after migration")
// Seed one user-scoped and one channel-scoped attribute in the same group.
group, err := store.PropertyGroup().Register(&model.PropertyGroup{Name: model.NewId(), Version: model.PropertyGroupVersionV1})
require.NoError(t, err)
groupID := group.ID
userField, err := store.PropertyField().Create(&model.PropertyField{
GroupID: groupID,
Name: "user_prop",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
})
require.NoError(t, err)
channelField, err := store.PropertyField().Create(&model.PropertyField{
GroupID: groupID,
Name: "channel_prop",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeChannel,
TargetType: string(model.PropertyFieldTargetLevelSystem),
})
require.NoError(t, err)
userTarget := model.NewId()
channelTarget := model.NewId()
userVal, err := store.PropertyValue().Create(&model.PropertyValue{
TargetID: userTarget, TargetType: model.PropertyValueTargetTypeUser,
GroupID: groupID, FieldID: userField.ID, Value: []byte(`"u"`),
})
require.NoError(t, err)
channelVal, err := store.PropertyValue().Create(&model.PropertyValue{
TargetID: channelTarget, TargetType: model.PropertyValueTargetTypeChannel,
GroupID: groupID, FieldID: channelField.ID, Value: []byte(`"c"`),
})
require.NoError(t, err)
t.Cleanup(func() {
store.PropertyValue().Delete(groupID, userVal.ID) //nolint:errcheck
store.PropertyValue().Delete(groupID, channelVal.ID) //nolint:errcheck
store.PropertyField().Delete(groupID, userField.ID) //nolint:errcheck
store.PropertyField().Delete(groupID, channelField.ID) //nolint:errcheck
})
require.NoError(t, store.Attributes().RefreshAttributes())
countInView := func(view, targetID string) int {
var c int
gErr := store.GetMaster().Get(&c, "SELECT COUNT(*) FROM "+view+" WHERE TargetID = $1", targetID)
require.NoError(t, gErr)
return c
}
require.Equal(t, 1, countInView("UserAttributeView", userTarget), "user row should be in UserAttributeView")
require.Equal(t, 0, countInView("UserAttributeView", channelTarget), "channel row should not be in UserAttributeView")
require.Equal(t, 1, countInView("ChannelAttributeView", channelTarget), "channel row should be in ChannelAttributeView")
require.Equal(t, 0, countInView("ChannelAttributeView", userTarget), "user row should not be in ChannelAttributeView")
// Down then up round-trips the view topology.
downSQL := readMigrationSQL(t, "000216_split_attribute_view_by_object_type.down.sql")
upSQL := readMigrationSQL(t, "000216_split_attribute_view_by_object_type.up.sql")
_, err = store.GetMaster().Exec(downSQL)
require.NoError(t, err)
require.True(t, matviewExists(t, store, "AttributeView"), "down should recreate AttributeView")
require.False(t, matviewExists(t, store, "UserAttributeView"), "down should drop UserAttributeView")
require.False(t, matviewExists(t, store, "ChannelAttributeView"), "down should drop ChannelAttributeView")
_, err = store.GetMaster().Exec(upSQL)
require.NoError(t, err)
require.True(t, matviewExists(t, store, "UserAttributeView"), "up should recreate UserAttributeView")
require.True(t, matviewExists(t, store, "ChannelAttributeView"), "up should recreate ChannelAttributeView")
require.False(t, matviewExists(t, store, "AttributeView"), "up should drop AttributeView")
}

View File

@@ -0,0 +1,121 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func TestMigration000217(t *testing.T) {
logger := mlog.CreateTestLogger(t)
settings, err := makeSqlSettings(model.DatabaseDriverPostgres)
if err != nil {
t.Skip(err)
}
store, err := New(*settings, logger, nil)
require.NoError(t, err)
defer store.Close()
master := store.GetMaster()
upSQL := readMigrationSQL(t, "000217_resize_message_columns.up.sql")
downSQL := readMigrationSQL(t, "000217_resize_message_columns.down.sql")
type tableCol struct {
table string
column string
}
targets := []tableCol{
{"posts", "message"},
{"drafts", "message"},
{"scheduledposts", "message"},
{"temporaryposts", "message"},
}
colLength := func(t *testing.T, table, column string) int {
t.Helper()
var length int
require.NoError(t, master.Get(&length, fmt.Sprintf(`
SELECT COALESCE(character_maximum_length, 0)
FROM information_schema.columns
WHERE table_name = '%s' AND column_name = '%s'
`, table, column)))
return length
}
setColLength := func(t *testing.T, table, column string, size int) {
t.Helper()
_, alterErr := master.ExecNoTimeout(fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s TYPE VARCHAR(%d)", table, column, size))
require.NoError(t, alterErr)
}
restoreTo := func(t *testing.T, size int) {
t.Helper()
for _, tc := range targets {
_, alterErr := master.ExecNoTimeout(fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s TYPE VARCHAR(%d)", tc.table, tc.column, size))
require.NoError(t, alterErr)
}
}
t.Run("NormalUpThenDown", func(t *testing.T) {
t.Cleanup(func() { restoreTo(t, 1048576) })
for _, tc := range targets {
setColLength(t, tc.table, tc.column, 65535)
}
_, err := master.ExecNoTimeout(upSQL)
require.NoError(t, err, "up migration should succeed")
for _, tc := range targets {
assert.Equal(t, 1048576, colLength(t, tc.table, tc.column), "%s.%s after up migration", tc.table, tc.column)
}
_, err = master.ExecNoTimeout(downSQL)
require.NoError(t, err, "down migration should succeed")
for _, tc := range targets {
assert.Equal(t, 65535, colLength(t, tc.table, tc.column), "%s.%s after down migration", tc.table, tc.column)
}
})
t.Run("UpSkipsWhenAlreadyLarger", func(t *testing.T) {
t.Cleanup(func() { restoreTo(t, 1048576) })
for _, tc := range targets {
setColLength(t, tc.table, tc.column, 2097152)
}
_, err := master.ExecNoTimeout(upSQL)
require.NoError(t, err, "up migration should succeed even when columns are already larger")
for _, tc := range targets {
assert.Equal(t, 2097152, colLength(t, tc.table, tc.column), "%s.%s should be unchanged by up migration", tc.table, tc.column)
}
})
t.Run("DownSkipsWhenLargerThanTarget", func(t *testing.T) {
t.Cleanup(func() { restoreTo(t, 1048576) })
for _, tc := range targets {
setColLength(t, tc.table, tc.column, 2097152)
}
_, err := master.ExecNoTimeout(downSQL)
require.NoError(t, err, "down migration should succeed even when columns exceed the target size")
for _, tc := range targets {
assert.Equal(t, 2097152, colLength(t, tc.table, tc.column), "%s.%s should be unchanged by down migration", tc.table, tc.column)
}
})
}

View File

@@ -0,0 +1,109 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"fmt"
"github.com/pkg/errors"
sq "github.com/mattermost/squirrel"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
type SqlNotifyAdminStore struct {
*SqlStore
notifyAdminQuery sq.SelectBuilder
}
func newSqlNotifyAdminStore(sqlStore *SqlStore) store.NotifyAdminStore {
s := &SqlNotifyAdminStore{
SqlStore: sqlStore,
}
s.notifyAdminQuery = s.getQueryBuilder().
Select(
"UserId",
"CreateAt",
"RequiredPlan",
"RequiredFeature",
"Trial",
"SentAt",
).
From("NotifyAdmin")
return s
}
func (s SqlNotifyAdminStore) insert(data *model.NotifyAdminData) (sql.Result, error) {
query := `INSERT INTO NotifyAdmin (UserId, CreateAt, RequiredPlan, RequiredFeature, Trial) VALUES (:UserId, :CreateAt, :RequiredPlan, :RequiredFeature, :Trial)`
return s.GetMaster().NamedExec(query, data)
}
func (s SqlNotifyAdminStore) Save(data *model.NotifyAdminData) (*model.NotifyAdminData, error) {
if err := data.IsValid(); err != nil {
return nil, err
}
data.PreSave()
_, err := s.insert(data)
if err != nil {
return nil, errors.Wrap(err, "failed to save Notify Admin data")
}
return data, nil
}
func (s SqlNotifyAdminStore) GetDataByUserIdAndFeature(userId string, feature model.MattermostFeature) ([]*model.NotifyAdminData, error) {
data := []*model.NotifyAdminData{}
query, args, err := s.notifyAdminQuery.
Where(sq.Eq{"UserId": userId, "RequiredFeature": feature}).
ToSql()
if err != nil {
return nil, errors.Wrap(err, "could not build sql query to get all notification data by user id and required feature")
}
if err := s.GetReplica().Select(&data, query, args...); err != nil {
if err == sql.ErrNoRows {
return nil, store.NewErrNotFound("NotifyAdmin", fmt.Sprintf("user id: %s and required feature: %s", userId, feature))
}
return nil, errors.Wrapf(err, "notifcation data by user id: %s and required feature: %s", userId, feature)
}
return data, nil
}
func (s SqlNotifyAdminStore) Get(trial bool) ([]*model.NotifyAdminData, error) {
data := []*model.NotifyAdminData{}
query, args, err := s.notifyAdminQuery.
Where(sq.Eq{"Trial": trial}).
Where("(SentAt IS NULL)").
ToSql()
if err != nil {
return nil, errors.Wrap(err, "could not build sql query to get all notifcation data")
}
if err := s.GetReplica().Select(&data, query, args...); err != nil {
return nil, errors.Wrap(err, "notifcation data")
}
return data, nil
}
func (s SqlNotifyAdminStore) DeleteBefore(trial bool, now int64) error {
if _, err := s.GetMaster().Exec("DELETE FROM NotifyAdmin WHERE Trial = ? AND CreateAt < ? AND SentAt IS NULL", trial, now); err != nil {
return errors.Wrapf(err, "failed to remove all notification data with trial=%t", trial)
}
return nil
}
func (s SqlNotifyAdminStore) Update(userId string, requiredPlan string, requiredFeature model.MattermostFeature, now int64) error {
if _, err := s.GetMaster().Exec("UPDATE NotifyAdmin SET SentAt = ? WHERE UserId = ? AND RequiredPlan = ? AND RequiredFeature = ?", now, userId, requiredPlan, requiredFeature); err != nil {
return errors.Wrapf(err, "failed to update SentAt for userId=%s and requiredPlan=%s", userId, requiredPlan)
}
return nil
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestNotifyAdminStore(t *testing.T) {
StoreTest(t, storetest.TestNotifyAdminStore)
}

View File

@@ -0,0 +1,372 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"fmt"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/store"
sq "github.com/mattermost/squirrel"
)
type SqlOAuthStore struct {
*SqlStore
oAuthAppsSelectQuery sq.SelectBuilder
oAuthAccessDataQuery sq.SelectBuilder
oAuthAuthDataQuery sq.SelectBuilder
}
func newSqlOAuthStore(sqlStore *SqlStore) store.OAuthStore {
s := SqlOAuthStore{
SqlStore: sqlStore,
}
s.oAuthAppsSelectQuery = s.getQueryBuilder().
Select("o.Id", "o.CreatorId", "o.CreateAt", "o.UpdateAt", "o.ClientSecret", "o.Name", "o.Description", "o.IconURL", "o.CallbackUrls", "o.Homepage", "o.IsTrusted", "o.MattermostAppID",
"o.IsDynamicallyRegistered").
From("OAuthApps o")
s.oAuthAccessDataQuery = s.getQueryBuilder().
Select("ClientId", "UserId", "Token", "RefreshToken", "RedirectUri", "ExpiresAt", "Scope", "Audience").
From("OAuthAccessData")
s.oAuthAuthDataQuery = s.getQueryBuilder().
Select("ClientId", "UserId", "Code", "ExpiresIn", "CreateAt", "RedirectUri", "State", "Scope", "CodeChallenge", "CodeChallengeMethod", "Resource").
From("OAuthAuthData")
return &s
}
func (as SqlOAuthStore) SaveApp(app *model.OAuthApp) (*model.OAuthApp, error) {
if app.Id != "" {
return nil, store.NewErrInvalidInput("OAuthApp", "Id", app.Id)
}
app.PreSave()
if err := app.IsValid(); err != nil {
return nil, err
}
if _, err := as.GetMaster().NamedExec(`INSERT INTO OAuthApps
(Id, CreatorId, CreateAt, UpdateAt, ClientSecret, Name, Description, IconURL, CallbackUrls, Homepage, IsTrusted, MattermostAppID,
IsDynamicallyRegistered)
VALUES
(:Id, :CreatorId, :CreateAt, :UpdateAt, :ClientSecret, :Name, :Description, :IconURL, :CallbackUrls, :Homepage, :IsTrusted, :MattermostAppID,
:IsDynamicallyRegistered)`, app); err != nil {
return nil, errors.Wrap(err, "failed to save OAuthApp")
}
return app, nil
}
func (as SqlOAuthStore) UpdateApp(app *model.OAuthApp) (*model.OAuthApp, error) {
app.PreUpdate()
if err := app.IsValid(); err != nil {
return nil, err
}
var oldApp model.OAuthApp
query := as.oAuthAppsSelectQuery.Where(sq.Eq{"o.Id": app.Id})
err := as.GetReplica().GetBuilder(&oldApp, query)
if err != nil {
return nil, errors.Wrapf(err, "failed to get OAuthApp with id=%s", app.Id)
}
if oldApp.Id == "" {
return nil, store.NewErrInvalidInput("OAuthApp", "Id", app.Id)
}
app.CreateAt = oldApp.CreateAt
app.CreatorId = oldApp.CreatorId
res, err := as.GetMaster().NamedExec(`UPDATE OAuthApps
SET UpdateAt=:UpdateAt, ClientSecret=:ClientSecret, Name=:Name,
Description=:Description, IconURL=:IconURL, CallbackUrls=:CallbackUrls,
Homepage=:Homepage, IsTrusted=:IsTrusted, MattermostAppID=:MattermostAppID,
IsDynamicallyRegistered=:IsDynamicallyRegistered
WHERE Id=:Id`, app)
if err != nil {
return nil, errors.Wrapf(err, "failed to update OAuthApp with id=%s", app.Id)
}
count, err := res.RowsAffected()
if err != nil {
return nil, errors.Wrap(err, "error while getting rows_affected")
}
if count > 1 {
return nil, store.NewErrInvalidInput("OAuthApp", "Id", app.Id)
}
return app, nil
}
func (as SqlOAuthStore) GetApp(id string) (*model.OAuthApp, error) {
var app model.OAuthApp
query := as.oAuthAppsSelectQuery.Where(sq.Eq{"o.Id": id})
if err := as.GetReplica().GetBuilder(&app, query); err != nil {
if err == sql.ErrNoRows {
return nil, store.NewErrNotFound("OAuthApp", id)
}
return nil, errors.Wrapf(err, "failed to get OAuthApp with id=%s", id)
}
if app.Id == "" {
return nil, store.NewErrNotFound("OAuthApp", id)
}
return &app, nil
}
func (as SqlOAuthStore) GetAppByUser(userId string, offset, limit int) ([]*model.OAuthApp, error) {
apps := []*model.OAuthApp{}
query := as.oAuthAppsSelectQuery.Where(sq.Eq{"o.CreatorId": userId}).Limit(uint64(limit)).Offset(uint64(offset))
if err := as.GetReplica().SelectBuilder(&apps, query); err != nil {
return nil, errors.Wrapf(err, "failed to find OAuthApps with userId=%s", userId)
}
return apps, nil
}
func (as SqlOAuthStore) GetApps(offset, limit int) ([]*model.OAuthApp, error) {
apps := []*model.OAuthApp{}
query := as.oAuthAppsSelectQuery.Limit(uint64(limit)).Offset(uint64(offset))
if err := as.GetReplica().SelectBuilder(&apps, query); err != nil {
return nil, errors.Wrap(err, "failed to find OAuthApps")
}
return apps, nil
}
func (as SqlOAuthStore) GetAuthorizedApps(userId string, offset, limit int) ([]*model.OAuthApp, error) {
apps := []*model.OAuthApp{}
query := as.oAuthAppsSelectQuery.
InnerJoin("Preferences AS p ON p.Name = o.Id AND p.UserId = ?", userId).
Limit(uint64(limit)).
Offset(uint64(offset))
if err := as.GetReplica().SelectBuilder(&apps, query); err != nil {
return nil, errors.Wrapf(err, "failed to find OAuthApps with userId=%s", userId)
}
return apps, nil
}
func (as SqlOAuthStore) DeleteApp(id string) (err error) {
// wrap in a transaction so that if one fails, everything fails
transaction, err := as.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "begin_transaction")
}
defer finalizeTransactionX(transaction, &err)
if err := as.deleteApp(transaction, id); err != nil {
return err
}
if err := transaction.Commit(); err != nil {
// don't need to rollback here since the transaction is already closed
return errors.Wrap(err, "commit_transaction")
}
return nil
}
func (as SqlOAuthStore) SaveAccessData(accessData *model.AccessData) (*model.AccessData, error) {
if err := accessData.IsValid(); err != nil {
return nil, err
}
if _, err := as.GetMaster().NamedExec(`INSERT INTO OAuthAccessData
(ClientId, UserId, Token, RefreshToken, RedirectUri, ExpiresAt, Scope, Audience)
VALUES
(:ClientId, :UserId, :Token, :RefreshToken, :RedirectUri, :ExpiresAt, :Scope, :Audience)`, accessData); err != nil {
return nil, errors.Wrap(err, "failed to save AccessData")
}
return accessData, nil
}
func (as SqlOAuthStore) GetAccessData(token string) (*model.AccessData, error) {
accessData := model.AccessData{}
query := as.oAuthAccessDataQuery.Where(sq.Eq{"Token": token})
if err := as.GetReplica().GetBuilder(&accessData, query); err != nil {
return nil, errors.Wrapf(err, "failed to get OAuthAccessData with token=%s", token)
}
return &accessData, nil
}
func (as SqlOAuthStore) GetAccessDataByUserForApp(userID, clientID string) ([]*model.AccessData, error) {
accessData := []*model.AccessData{}
query := as.oAuthAccessDataQuery.Where(sq.Eq{"UserId": userID, "ClientId": clientID})
if err := as.GetReplica().SelectBuilder(&accessData, query); err != nil {
return nil, errors.Wrapf(err, "failed to delete OAuthAccessData with userId=%s and clientId=%s", userID, clientID)
}
return accessData, nil
}
func (as SqlOAuthStore) GetAccessDataByRefreshToken(token string) (*model.AccessData, error) {
accessData := model.AccessData{}
query := as.oAuthAccessDataQuery.Where(sq.Eq{"RefreshToken": token})
if err := as.GetReplica().GetBuilder(&accessData, query); err != nil {
return nil, errors.Wrapf(err, "failed to find OAuthAccessData with refreshToken=%s", token)
}
return &accessData, nil
}
func (as SqlOAuthStore) GetPreviousAccessData(userID, clientID string) (*model.AccessData, error) {
accessData := model.AccessData{}
query := as.oAuthAccessDataQuery.Where(sq.Eq{"UserId": userID, "ClientId": clientID}).
OrderBy("ExpiresAt DESC").Limit(1)
if err := as.GetReplica().GetBuilder(&accessData, query); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, errors.Wrapf(err, "failed to find OAuthAccessData with userId=%s and clientId=%s", userID, clientID)
}
return &accessData, nil
}
func (as SqlOAuthStore) UpdateAccessData(accessData *model.AccessData) (*model.AccessData, error) {
if err := accessData.IsValid(); err != nil {
return nil, err
}
if _, err := as.GetMaster().NamedExec("UPDATE OAuthAccessData SET Token = :Token, ExpiresAt = :ExpiresAt, RefreshToken = :RefreshToken, Audience = :Audience WHERE ClientId = :ClientId AND UserID = :UserId", accessData); err != nil {
return nil, errors.Wrapf(err, "failed to update OAuthAccessData with userId=%s and clientId=%s", accessData.UserId, accessData.ClientId)
}
return accessData, nil
}
func (as SqlOAuthStore) RemoveAccessData(token string) error {
if _, err := as.GetMaster().Exec("DELETE FROM OAuthAccessData WHERE Token = ?", token); err != nil {
return errors.Wrapf(err, "failed to delete OAuthAccessData with token=%s", token)
}
return nil
}
func (as SqlOAuthStore) RemoveAllAccessData() error {
if _, err := as.GetMaster().Exec("DELETE FROM OAuthAccessData"); err != nil {
return errors.Wrap(err, "failed to delete OAuthAccessData")
}
return nil
}
func (as SqlOAuthStore) SaveAuthData(authData *model.AuthData) (*model.AuthData, error) {
authData.PreSave()
if err := authData.IsValid(); err != nil {
return nil, err
}
if _, err := as.GetMaster().NamedExec(`INSERT INTO OAuthAuthData
(ClientId, UserId, Code, ExpiresIn, CreateAt, RedirectUri, State, Scope, CodeChallenge, CodeChallengeMethod, Resource)
VALUES
(:ClientId, :UserId, :Code, :ExpiresIn, :CreateAt, :RedirectUri, :State, :Scope, :CodeChallenge, :CodeChallengeMethod, :Resource)`, authData); err != nil {
return nil, errors.Wrap(err, "failed to save AuthData")
}
return authData, nil
}
func (as SqlOAuthStore) GetAuthData(code string) (*model.AuthData, error) {
var authData model.AuthData
query := as.oAuthAuthDataQuery.Where(sq.Eq{"Code": code})
if err := as.GetReplica().GetBuilder(&authData, query); err != nil {
if err == sql.ErrNoRows {
return nil, store.NewErrNotFound("AuthData", fmt.Sprintf("code=%s", code))
}
return nil, errors.Wrapf(err, "failed to get AuthData with code=%s", code)
}
if authData.Code == "" {
return nil, store.NewErrNotFound("AuthData", fmt.Sprintf("code=%s", code))
}
return &authData, nil
}
func (as SqlOAuthStore) RemoveAuthData(code string) error {
_, err := as.GetMaster().Exec("DELETE FROM OAuthAuthData WHERE Code = ?", code)
if err != nil {
return errors.Wrapf(err, "failed to delete AuthData with code=%s", code)
}
return nil
}
func (as SqlOAuthStore) RemoveAuthDataByClientId(clientId string, userId string) error {
_, err := as.GetMaster().Exec("DELETE FROM OAuthAuthData WHERE ClientId = ? and UserId = ?", clientId, userId)
if err != nil {
return errors.Wrapf(err, "failed to delete AuthData with clientId=%s and userId=%s", clientId, userId)
}
return nil
}
func (as SqlOAuthStore) RemoveAuthDataByUserId(userId string) error {
_, err := as.GetMaster().Exec("DELETE FROM OAuthAuthData WHERE UserId = ?", userId)
if err != nil {
return errors.Wrapf(err, "failed to delete AuthData with userId=%s", userId)
}
return nil
}
func (as SqlOAuthStore) PermanentDeleteAuthDataByUser(userId string) error {
_, err := as.GetMaster().Exec("DELETE FROM OAuthAccessData WHERE UserId = ?", userId)
if err != nil {
return errors.Wrapf(err, "failed to delete OAuthAccessData with userId=%s", userId)
}
return nil
}
func (as SqlOAuthStore) deleteApp(transaction *sqlxTxWrapper, clientId string) error {
if _, err := transaction.Exec("DELETE FROM OAuthApps WHERE Id = ?", clientId); err != nil {
return errors.Wrapf(err, "failed to delete OAuthApp with id=%s", clientId)
}
return as.deleteOAuthAppSessions(transaction, clientId)
}
func (as SqlOAuthStore) deleteOAuthAppSessions(transaction *sqlxTxWrapper, clientId string) error {
query := "DELETE FROM Sessions s USING OAuthAccessData o WHERE o.Token = s.Token AND o.ClientId = ?"
if _, err := transaction.Exec(query, clientId); err != nil {
return errors.Wrapf(err, "failed to delete Session with OAuthAccessData.Id=%s", clientId)
}
return as.deleteOAuthTokens(transaction, clientId)
}
func (as SqlOAuthStore) deleteOAuthTokens(transaction *sqlxTxWrapper, clientId string) error {
if _, err := transaction.Exec("DELETE FROM OAuthAccessData WHERE ClientId = ?", clientId); err != nil {
return errors.Wrapf(err, "failed to delete OAuthAccessData with id=%s", clientId)
}
return as.deleteAppExtras(transaction, clientId)
}
func (as SqlOAuthStore) deleteAppExtras(transaction *sqlxTxWrapper, clientId string) error {
if _, err := transaction.Exec(
`DELETE FROM
Preferences
WHERE
Category = ?
AND Name = ?`, model.PreferenceCategoryAuthorizedOAuthApp, clientId); err != nil {
return errors.Wrapf(err, "failed to delete Preferences with name=%s", clientId)
}
return nil
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestOAuthStore(t *testing.T) {
StoreTest(t, storetest.TestOAuthStore)
}

View File

@@ -0,0 +1,136 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"fmt"
sq "github.com/mattermost/squirrel"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/pkg/errors"
)
type SqlOutgoingOAuthConnectionStore struct {
*SqlStore
tableSelectQuery sq.SelectBuilder
}
func newSqlOutgoingOAuthConnectionStore(sqlStore *SqlStore) store.OutgoingOAuthConnectionStore {
s := SqlOutgoingOAuthConnectionStore{
SqlStore: sqlStore,
}
s.tableSelectQuery = s.getQueryBuilder().
Select("Id", "CreatorId", "CreateAt", "UpdateAt", "Name", "ClientId", "ClientSecret", "CredentialsUsername", "CredentialsPassword", "OAuthTokenURL", "GrantType", "Audiences").
From("OutgoingOAuthConnections")
return &s
}
func (s *SqlOutgoingOAuthConnectionStore) SaveConnection(rctx request.CTX, conn *model.OutgoingOAuthConnection) (*model.OutgoingOAuthConnection, error) {
if conn.Id != "" {
return nil, store.NewErrInvalidInput("OutgoingOAuthConnection", "Id", conn.Id)
}
conn.PreSave()
if err := conn.IsValid(); err != nil {
return nil, err
}
if _, err := s.GetMaster().NamedExec(`INSERT INTO OutgoingOAuthConnections
(Id, Name, ClientId, ClientSecret, CreateAt, UpdateAt, CreatorId, OAuthTokenURL, GrantType, Audiences)
VALUES
(:Id, :Name, :ClientId, :ClientSecret, :CreateAt, :UpdateAt, :CreatorId, :OAuthTokenURL, :GrantType, :Audiences)`, conn); err != nil {
return nil, errors.Wrap(err, "failed to save OutgoingOAuthConnection")
}
return conn, nil
}
func (s *SqlOutgoingOAuthConnectionStore) UpdateConnection(rctx request.CTX, conn *model.OutgoingOAuthConnection) (*model.OutgoingOAuthConnection, error) {
if conn.Id == "" {
return nil, store.NewErrInvalidInput("OutgoingOAuthConnection", "Id", conn.Id)
}
conn.PreUpdate()
if err := conn.IsValid(); err != nil {
return nil, err
}
query := s.getQueryBuilder().Update("OutgoingOAuthConnections").Where(sq.Eq{"Id": conn.Id}).Set("UpdateAt", conn.UpdateAt)
if conn.Name != "" {
query = query.Set("Name", conn.Name)
}
if conn.ClientId != "" {
query = query.Set("ClientId", conn.ClientId)
}
if conn.ClientSecret != "" {
query = query.Set("ClientSecret", conn.ClientSecret)
}
if conn.OAuthTokenURL != "" {
query = query.Set("OAuthTokenURL", conn.OAuthTokenURL)
}
if conn.GrantType != "" {
query = query.Set("GrantType", conn.GrantType)
}
if len(conn.Audiences) > 0 {
query = query.Set("Audiences", conn.Audiences)
}
if conn.CredentialsUsername != nil {
query = query.Set("CredentialsUsername", conn.CredentialsUsername)
}
if conn.CredentialsPassword != nil {
query = query.Set("CredentialsPassword", conn.CredentialsPassword)
}
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
return nil, errors.Wrap(err, "failed to update OutgoingOAuthConnection")
}
return conn, nil
}
func (s *SqlOutgoingOAuthConnectionStore) GetConnection(rctx request.CTX, id string) (*model.OutgoingOAuthConnection, error) {
conn := &model.OutgoingOAuthConnection{}
query := s.tableSelectQuery.Where(sq.Eq{"Id": id})
if err := s.GetReplica().GetBuilder(conn, query); err != nil {
if err == sql.ErrNoRows {
return nil, store.NewErrNotFound("OutgoingOAuthConnection", id)
}
return nil, errors.Wrap(err, "failed to get OutgoingOAuthConnection")
}
return conn, nil
}
func (s *SqlOutgoingOAuthConnectionStore) GetConnections(rctx request.CTX, filters model.OutgoingOAuthConnectionGetConnectionsFilter) ([]*model.OutgoingOAuthConnection, error) {
filters.SetDefaults()
conns := []*model.OutgoingOAuthConnection{}
query := s.tableSelectQuery.OrderBy("Id").Limit(uint64(filters.Limit))
if filters.OffsetId != "" {
query = query.Where("Id > ?", filters.OffsetId)
}
if filters.Audience != "" {
query = query.Where(sq.Like{"Audiences": fmt.Sprint("%", filters.Audience, "%")})
}
if err := s.GetReplica().SelectBuilderCtx(rctx.Context(), &conns, query); err != nil {
return nil, errors.Wrap(err, "failed to get OutgoingOAuthConnections")
}
return conns, nil
}
func (s *SqlOutgoingOAuthConnectionStore) DeleteConnection(rctx request.CTX, id string) error {
if _, err := s.GetMaster().Exec(`DELETE FROM OutgoingOAuthConnections WHERE Id=?`, id); err != nil {
return errors.Wrap(err, "failed to delete OutgoingOAuthConnection")
}
return nil
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestOutgoingOAuthConnectionStore(t *testing.T) {
StoreTest(t, storetest.TestOutgoingOAuthConnectionStore)
}

View File

@@ -0,0 +1,316 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"fmt"
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
const (
defaultPluginKeyFetchLimit = 10
)
type SqlPluginStore struct {
*SqlStore
}
func newSqlPluginStore(sqlStore *SqlStore) store.PluginStore {
return &SqlPluginStore{sqlStore}
}
func (ps SqlPluginStore) SaveOrUpdate(kv *model.PluginKeyValue) (*model.PluginKeyValue, error) {
if err := kv.IsValid(); err != nil {
return nil, err
}
if kv.Value == nil {
// Setting a key to nil is the same as removing it
err := ps.Delete(kv.PluginId, kv.Key)
if err != nil {
return nil, err
}
return kv, nil
}
query := ps.getQueryBuilder().
Insert("PluginKeyValueStore").
Columns("PluginId", "PKey", "PValue", "ExpireAt").
Values(kv.PluginId, kv.Key, kv.Value, kv.ExpireAt)
query = query.SuffixExpr(sq.Expr("ON CONFLICT (pluginid, pkey) DO UPDATE SET PValue = ?, ExpireAt = ?", kv.Value, kv.ExpireAt))
queryString, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrap(err, "plugin_tosql")
}
if _, err := ps.GetMaster().Exec(queryString, args...); err != nil {
return nil, errors.Wrap(err, "failed to upsert PluginKeyValue")
}
return kv, nil
}
func (ps SqlPluginStore) CompareAndSet(kv *model.PluginKeyValue, oldValue []byte) (bool, error) {
if err := kv.IsValid(); err != nil {
return false, err
}
if kv.Value == nil {
// Setting a key to nil is the same as removing it
return ps.CompareAndDelete(kv, oldValue)
}
if oldValue == nil {
// Delete any existing, expired value.
query := ps.getQueryBuilder().
Delete("PluginKeyValueStore").
Where(sq.Eq{"PluginId": kv.PluginId}).
Where(sq.Eq{"PKey": kv.Key}).
Where(sq.NotEq{"ExpireAt": int(0)}).
Where(sq.Lt{"ExpireAt": model.GetMillis()})
queryString, args, err := query.ToSql()
if err != nil {
return false, errors.Wrap(err, "plugin_tosql")
}
if _, err = ps.GetMaster().Exec(queryString, args...); err != nil {
return false, errors.Wrap(err, "failed to delete PluginKeyValue")
}
// Insert if oldValue is nil
queryString, args, err = ps.getQueryBuilder().
Insert("PluginKeyValueStore").
Columns("PluginId", "PKey", "PValue", "ExpireAt").
Values(kv.PluginId, kv.Key, kv.Value, kv.ExpireAt).ToSql()
if err != nil {
return false, errors.Wrap(err, "plugin_tosql")
}
if _, err := ps.GetMaster().Exec(queryString, args...); err != nil {
// If the error is from unique constraints violation, it's the result of a
// race condition, return false and no error. Otherwise we have a real error and
// need to return it.
if IsUniqueConstraintError(err, []string{"PRIMARY", "PluginId", "Key", "PKey", "pkey"}) {
return false, nil
}
return false, errors.Wrap(err, "failed to insert PluginKeyValue")
}
} else {
currentTime := model.GetMillis()
// Update if oldValue is not nil
query := ps.getQueryBuilder().
Update("PluginKeyValueStore").
Set("PValue", kv.Value).
Set("ExpireAt", kv.ExpireAt).
Where(sq.Eq{"PluginId": kv.PluginId}).
Where(sq.Eq{"PKey": kv.Key}).
Where(sq.Eq{"PValue": oldValue}).
Where(sq.Or{
sq.Eq{"ExpireAt": int(0)},
sq.Gt{"ExpireAt": currentTime},
})
queryString, args, err := query.ToSql()
if err != nil {
return false, errors.Wrap(err, "plugin_tosql")
}
updateResult, err := ps.GetMaster().Exec(queryString, args...)
if err != nil {
return false, errors.Wrap(err, "failed to update PluginKeyValue")
}
if rowsAffected, err := updateResult.RowsAffected(); err != nil {
// Failed to update
return false, errors.Wrap(err, "unable to get rows affected")
} else if rowsAffected == 0 {
// No rows were affected by the update, where condition was not satisfied,
// return false, but no error.
return false, nil
}
}
return true, nil
}
func (ps SqlPluginStore) CompareAndDelete(kv *model.PluginKeyValue, oldValue []byte) (bool, error) {
if err := kv.IsValid(); err != nil {
return false, err
}
if oldValue == nil {
// nil can't be stored. Return showing that we didn't do anything
return false, nil
}
query := ps.getQueryBuilder().
Delete("PluginKeyValueStore").
Where(sq.Eq{"PluginId": kv.PluginId}).
Where(sq.Eq{"PKey": kv.Key}).
Where(sq.Eq{"PValue": oldValue}).
Where(sq.Or{
sq.Eq{"ExpireAt": int(0)},
sq.Gt{"ExpireAt": model.GetMillis()},
})
queryString, args, err := query.ToSql()
if err != nil {
return false, errors.Wrap(err, "plugin_tosql")
}
deleteResult, err := ps.GetMaster().Exec(queryString, args...)
if err != nil {
return false, errors.Wrap(err, "failed to delete PluginKeyValue")
}
if rowsAffected, err := deleteResult.RowsAffected(); err != nil {
return false, errors.Wrap(err, "unable to get rows affected")
} else if rowsAffected == 0 {
return false, nil
}
return true, nil
}
func (ps SqlPluginStore) SetWithOptions(pluginId string, key string, value []byte, opt model.PluginKVSetOptions) (bool, error) {
if err := opt.IsValid(); err != nil {
return false, err
}
kv, err := model.NewPluginKeyValueFromOptions(pluginId, key, value, opt)
if err != nil {
return false, err
}
if opt.Atomic {
return ps.CompareAndSet(kv, opt.OldValue)
}
savedKv, nErr := ps.SaveOrUpdate(kv)
if nErr != nil {
return false, nErr
}
return savedKv != nil, nil
}
func (ps SqlPluginStore) Get(pluginId, key string) (*model.PluginKeyValue, error) {
currentTime := model.GetMillis()
query := ps.getQueryBuilder().Select("PluginId, PKey, PValue, ExpireAt").
From("PluginKeyValueStore").
Where(sq.Eq{"PluginId": pluginId}).
Where(sq.Eq{"PKey": key}).
Where(sq.Or{sq.Eq{"ExpireAt": 0}, sq.Gt{"ExpireAt": currentTime}})
queryString, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrap(err, "plugin_tosql")
}
row := ps.GetReplica().QueryRow(queryString, args...)
var kv model.PluginKeyValue
if err := row.Scan(&kv.PluginId, &kv.Key, &kv.Value, &kv.ExpireAt); err != nil {
if err == sql.ErrNoRows {
return nil, store.NewErrNotFound("PluginKeyValue", fmt.Sprintf("pluginId=%s, key=%s", pluginId, key))
}
return nil, errors.Wrapf(err, "failed to get PluginKeyValue with pluginId=%s and key=%s", pluginId, key)
}
return &kv, nil
}
func (ps SqlPluginStore) Delete(pluginId, key string) error {
query := ps.getQueryBuilder().
Delete("PluginKeyValueStore").
Where(sq.Eq{"PluginId": pluginId}).
Where(sq.Eq{"Pkey": key})
queryString, args, err := query.ToSql()
if err != nil {
return errors.Wrap(err, "plugin_tosql")
}
if _, err := ps.GetMaster().Exec(queryString, args...); err != nil {
return errors.Wrapf(err, "failed to delete PluginKeyValue with pluginId=%s and key=%s", pluginId, key)
}
return nil
}
func (ps SqlPluginStore) DeleteAllForPlugin(pluginId string) error {
query := ps.getQueryBuilder().
Delete("PluginKeyValueStore").
Where(sq.Eq{"PluginId": pluginId})
queryString, args, err := query.ToSql()
if err != nil {
return errors.Wrap(err, "plugin_tosql")
}
if _, err := ps.GetMaster().Exec(queryString, args...); err != nil {
return errors.Wrapf(err, "failed to get all PluginKeyValues with pluginId=%s ", pluginId)
}
return nil
}
func (ps SqlPluginStore) DeleteAllExpired() error {
currentTime := model.GetMillis()
query := ps.getQueryBuilder().
Delete("PluginKeyValueStore").
Where(sq.NotEq{"ExpireAt": 0}).
Where(sq.Lt{"ExpireAt": currentTime})
queryString, args, err := query.ToSql()
if err != nil {
return errors.Wrap(err, "plugin_tosql")
}
if _, err := ps.GetMaster().Exec(queryString, args...); err != nil {
return errors.Wrap(err, "failed to delete all expired PluginKeyValues")
}
return nil
}
func (ps SqlPluginStore) List(pluginId string, offset int, limit int) ([]string, error) {
if limit <= 0 {
limit = defaultPluginKeyFetchLimit
}
if offset <= 0 {
offset = 0
}
query := ps.getQueryBuilder().
Select("Pkey").
From("PluginKeyValueStore").
Where(sq.Eq{"PluginId": pluginId}).
Where(sq.Or{
sq.Eq{"ExpireAt": int(0)},
sq.Gt{"ExpireAt": model.GetMillis()},
}).
OrderBy("PKey").
Limit(uint64(limit)).
Offset(uint64(offset))
queryString, args, err := query.ToSql()
if err != nil {
return nil, errors.Wrap(err, "plugin_tosql")
}
keys := []string{}
err = ps.GetReplica().Select(&keys, queryString, args...)
if err != nil {
return nil, errors.Wrapf(err, "failed to get PluginKeyValues with pluginId=%s", pluginId)
}
return keys, nil
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestPluginStore(t *testing.T) {
StoreTestWithSqlStore(t, storetest.TestPluginStore)
}

View File

@@ -0,0 +1,372 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
type SqlPostAcknowledgementStore struct {
*SqlStore
}
func newSqlPostAcknowledgementStore(sqlStore *SqlStore) store.PostAcknowledgementStore {
return &SqlPostAcknowledgementStore{sqlStore}
}
func (s *SqlPostAcknowledgementStore) Get(postID, userID string) (*model.PostAcknowledgement, error) {
query := s.getQueryBuilder().
Select("PostId", "UserId", "ChannelId", "AcknowledgedAt", "RemoteId").
From("PostAcknowledgements").
Where(sq.And{
sq.Eq{"PostId": postID},
sq.Eq{"UserId": userID},
sq.NotEq{"AcknowledgedAt": 0},
})
var acknowledgement model.PostAcknowledgement
err := s.GetReplica().GetBuilder(&acknowledgement, query)
if err != nil {
if err == sql.ErrNoRows {
return nil, store.NewErrNotFound("PostAcknowledgement", postID)
}
return nil, err
}
return &acknowledgement, nil
}
func (s *SqlPostAcknowledgementStore) SaveWithModel(acknowledgement *model.PostAcknowledgement) (*model.PostAcknowledgement, error) {
if err := acknowledgement.IsValid(); err != nil {
return nil, err
}
acknowledgement.PreSave()
transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
defer finalizeTransactionX(transaction, &err)
query := s.buildUpsertQuery(acknowledgement)
_, err = transaction.ExecBuilder(query)
if err != nil {
return nil, err
}
err = updatePost(transaction, acknowledgement.PostId)
if err != nil {
return nil, err
}
err = transaction.Commit()
if err != nil {
return nil, errors.Wrap(err, "commit_transaction")
}
return acknowledgement, nil
}
func (s *SqlPostAcknowledgementStore) Delete(acknowledgement *model.PostAcknowledgement) error {
transaction, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "begin_transaction")
}
defer finalizeTransactionX(transaction, &err)
query := s.getQueryBuilder().
Update("PostAcknowledgements").
Set("AcknowledgedAt", 0).
Where(sq.And{
sq.Eq{"PostId": acknowledgement.PostId},
sq.Eq{"UserId": acknowledgement.UserId},
})
_, err = transaction.ExecBuilder(query)
if err != nil {
return err
}
err = updatePost(transaction, acknowledgement.PostId)
if err != nil {
return err
}
err = transaction.Commit()
if err != nil {
return errors.Wrap(err, "commit_transaction")
}
return nil
}
func (s *SqlPostAcknowledgementStore) DeleteAllForPost(postID string) error {
query := s.getQueryBuilder().
Delete("PostAcknowledgements").
Where(sq.Eq{"PostId": postID})
_, err := s.GetMaster().ExecBuilder(query)
return err
}
func (s *SqlPostAcknowledgementStore) GetForPost(postID string) ([]*model.PostAcknowledgement, error) {
var acknowledgements []*model.PostAcknowledgement
query := s.getQueryBuilder().
Select("PostId", "UserId", "ChannelId", "AcknowledgedAt", "RemoteId").
From("PostAcknowledgements").
Where(sq.And{
sq.NotEq{"AcknowledgedAt": 0},
sq.Eq{"PostId": postID},
})
err := s.GetReplica().SelectBuilder(&acknowledgements, query)
if err != nil {
return nil, errors.Wrapf(err, "failed to get PostAcknowledgements for postID=%s", postID)
}
return acknowledgements, nil
}
func (s *SqlPostAcknowledgementStore) GetForPosts(postIds []string) ([]*model.PostAcknowledgement, error) {
var acknowledgements []*model.PostAcknowledgement
perPage := 200
for i := 0; i < len(postIds); i += perPage {
j := min(len(postIds), i+perPage)
query := s.getQueryBuilder().
Select("PostId", "UserId", "ChannelId", "AcknowledgedAt", "RemoteId").
From("PostAcknowledgements").
Where(sq.And{
sq.Eq{"PostId": postIds[i:j]},
sq.NotEq{"AcknowledgedAt": 0},
})
var acknowledgementsBatch []*model.PostAcknowledgement
err := s.GetReplica().SelectBuilder(&acknowledgementsBatch, query)
if err != nil {
return nil, errors.Wrapf(err, "failed to get PostAcknowledgements for post list")
}
acknowledgements = append(acknowledgements, acknowledgementsBatch...)
}
return acknowledgements, nil
}
func (s *SqlPostAcknowledgementStore) GetForPostSince(postID string, since int64, excludeRemoteID string, inclDeleted bool) ([]*model.PostAcknowledgement, error) {
var acknowledgements []*model.PostAcknowledgement
query := s.getQueryBuilder().
Select("PostId", "UserId", "ChannelId", "AcknowledgedAt", "RemoteId").
From("PostAcknowledgements").
Where(sq.Eq{"PostId": postID})
if !inclDeleted {
query = query.Where(sq.NotEq{"AcknowledgedAt": 0})
}
if since > 0 {
query = query.Where(sq.Gt{"AcknowledgedAt": since})
}
if excludeRemoteID != "" {
query = query.Where(sq.NotEq{"COALESCE(RemoteId, '')": excludeRemoteID})
}
err := s.GetReplica().SelectBuilder(&acknowledgements, query)
if err != nil {
return nil, errors.Wrapf(err, "failed to get PostAcknowledgements for postID=%s since=%d", postID, since)
}
return acknowledgements, nil
}
func (s *SqlPostAcknowledgementStore) GetSingle(userID, postID, remoteID string) (*model.PostAcknowledgement, error) {
query := s.getQueryBuilder().
Select("PostId", "UserId", "ChannelId", "AcknowledgedAt", "RemoteId").
From("PostAcknowledgements").
Where(sq.And{
sq.Eq{"PostId": postID},
sq.Eq{"UserId": userID},
})
if remoteID != "" {
query = query.Where(sq.Eq{"RemoteId": remoteID})
} else {
query = query.Where(sq.Or{
sq.Eq{"RemoteId": ""},
sq.Eq{"RemoteId": nil},
})
}
var acknowledgement model.PostAcknowledgement
err := s.GetReplica().GetBuilder(&acknowledgement, query)
if err != nil {
if err == sql.ErrNoRows {
return nil, store.NewErrNotFound("PostAcknowledgement", postID)
}
return nil, err
}
return &acknowledgement, nil
}
// buildUpsertQuery creates an upsert query for a PostAcknowledgement
func (s *SqlPostAcknowledgementStore) buildUpsertQuery(acknowledgement *model.PostAcknowledgement) sq.InsertBuilder {
columnsToInsert := []string{"PostId", "UserId", "ChannelId", "AcknowledgedAt", "RemoteId"}
var remoteIdValue any
if acknowledgement.RemoteId != nil {
remoteIdValue = *acknowledgement.RemoteId
} else {
remoteIdValue = nil
}
valuesToInsert := []any{acknowledgement.PostId, acknowledgement.UserId, acknowledgement.ChannelId, acknowledgement.AcknowledgedAt, remoteIdValue}
query := s.getQueryBuilder().
Insert("PostAcknowledgements").
Columns(columnsToInsert...).
Values(valuesToInsert...)
query = query.SuffixExpr(sq.Expr("ON CONFLICT (postid, userid) DO UPDATE SET AcknowledgedAt = ?", acknowledgement.AcknowledgedAt))
return query
}
func updatePost(transaction *sqlxTxWrapper, postId string) error {
_, err := transaction.Exec(
`UPDATE
Posts
SET
UpdateAt = ?
WHERE
Id = ?`,
model.GetMillis(),
postId,
)
return err
}
func (s *SqlPostAcknowledgementStore) BatchSave(acknowledgements []*model.PostAcknowledgement) ([]*model.PostAcknowledgement, error) {
if len(acknowledgements) == 0 {
return []*model.PostAcknowledgement{}, nil
}
// Populate missing ChannelId fields and validate all acknowledgements
for _, ack := range acknowledgements {
// If ChannelId is not set, look it up from the post
if ack.ChannelId == "" {
postQuery := s.getQueryBuilder().
Select("ChannelId").
From("Posts").
Where(sq.Eq{"Id": ack.PostId})
var channelId string
err := s.GetReplica().GetBuilder(&channelId, postQuery)
if err != nil {
return nil, errors.Wrapf(err, "failed to get channel id for post %s", ack.PostId)
}
ack.ChannelId = channelId
}
if err := ack.IsValid(); err != nil {
return nil, err
}
}
transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
defer finalizeTransactionX(transaction, &err)
// Keep track of which posts need to be updated
postsToUpdate := make(map[string]bool)
// Insert all acknowledgements
for _, ack := range acknowledgements {
ack.PreSave()
query := s.buildUpsertQuery(ack)
_, err = transaction.ExecBuilder(query)
if err != nil {
return nil, err
}
postsToUpdate[ack.PostId] = true
}
// Update the UpdateAt timestamp for all affected posts
for postID := range postsToUpdate {
err = updatePost(transaction, postID)
if err != nil {
return nil, err
}
}
err = transaction.Commit()
if err != nil {
return nil, errors.Wrap(err, "commit_transaction")
}
return acknowledgements, nil
}
func (s *SqlPostAcknowledgementStore) BatchDelete(acknowledgements []*model.PostAcknowledgement) error {
if len(acknowledgements) == 0 {
return nil
}
transaction, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "begin_transaction")
}
defer finalizeTransactionX(transaction, &err)
// Keep track of which posts need to be updated
postsToUpdate := make(map[string]bool)
// Set AcknowledgedAt to 0 for all acknowledgements
for _, ack := range acknowledgements {
query := s.getQueryBuilder().
Update("PostAcknowledgements").
Set("AcknowledgedAt", 0).
Where(sq.And{
sq.Eq{"PostId": ack.PostId},
sq.Eq{"UserId": ack.UserId},
})
_, err = transaction.ExecBuilder(query)
if err != nil {
return err
}
postsToUpdate[ack.PostId] = true
}
// Update the UpdateAt timestamp for all affected posts
for postID := range postsToUpdate {
err = updatePost(transaction, postID)
if err != nil {
return err
}
}
err = transaction.Commit()
if err != nil {
return errors.Wrap(err, "commit_transaction")
}
return nil
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestPostAcknowledgementsStore(t *testing.T) {
StoreTestWithSqlStore(t, storetest.TestPostAcknowledgementsStore)
}

View File

@@ -0,0 +1,173 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/store"
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
)
type SqlPostPersistentNotificationStore struct {
*SqlStore
}
func newSqlPostPersistentNotificationStore(sqlStore *SqlStore) store.PostPersistentNotificationStore {
return &SqlPostPersistentNotificationStore{
SqlStore: sqlStore,
}
}
func (s *SqlPostPersistentNotificationStore) GetSingle(postID string) (*model.PostPersistentNotifications, error) {
builder := s.getQueryBuilder().
Select("PostId, CreateAt, LastSentAt, DeleteAt, SentCount").
From("PersistentNotifications").
Where(sq.And{
sq.Eq{"DeleteAt": 0},
sq.Eq{"PostId": postID},
})
post := &model.PostPersistentNotifications{}
err := s.GetReplica().GetBuilder(post, builder)
if err != nil {
if err == sql.ErrNoRows {
return nil, store.NewErrNotFound("Persistent Notification Post", postID)
}
return nil, errors.Wrapf(err, "failed to get the persistent notification post=%s", postID)
}
return post, nil
}
// Get returns only valid posts.
func (s *SqlPostPersistentNotificationStore) Get(params model.GetPersistentNotificationsPostsParams) ([]*model.PostPersistentNotifications, error) {
if params.PerPage == 0 {
params.PerPage = 1000
}
builder := s.getQueryBuilder().
Select("PostId, CreateAt, LastSentAt, DeleteAt, SentCount").
From("PersistentNotifications").
Where(sq.And{
sq.Eq{"DeleteAt": 0},
sq.LtOrEq{"CreateAt": params.MaxTime},
sq.LtOrEq{"LastSentAt": params.MaxTime},
sq.Lt{"SentCount": params.MaxSentCount},
}).
Limit(uint64(params.PerPage))
var posts []*model.PostPersistentNotifications
// Replica may not have the latest changes(done by UpdateLastActivity func)
// by the time this Get func is called again in the loop.
err := s.GetMaster().SelectBuilder(&posts, builder)
if err != nil {
return nil, errors.Wrap(err, "failed to get notifications")
}
return posts, nil
}
func (s *SqlPostPersistentNotificationStore) UpdateLastActivity(postIds []string) error {
builder := s.getQueryBuilder().
Update("PersistentNotifications").
Set("LastSentAt", model.GetMillis()).
Set("SentCount", sq.Expr("SentCount+1")).
Where(sq.Eq{"PostId": postIds})
_, err := s.GetMaster().ExecBuilder(builder)
if err != nil {
return errors.Wrapf(err, "failed to update last activity for posts %s", postIds)
}
return nil
}
func (s *SqlPostPersistentNotificationStore) Delete(postIds []string) error {
count := len(postIds)
if count == 0 {
return nil
}
builder := s.getQueryBuilder().
Update("PersistentNotifications").
Set("DeleteAt", model.GetMillis()).
Where(sq.Eq{"PostId": postIds})
_, err := s.GetMaster().ExecBuilder(builder)
if err != nil {
return errors.Wrapf(err, "failed to delete notifications for posts %s", postIds)
}
return nil
}
func (s *SqlPostPersistentNotificationStore) DeleteExpired(maxSentCount int16) error {
builder := s.getQueryBuilder().
Update("PersistentNotifications").
Set("DeleteAt", model.GetMillis()).
Where(sq.And{
sq.Eq{"DeleteAt": 0},
sq.GtOrEq{"SentCount": maxSentCount},
})
_, err := s.GetMaster().ExecBuilder(builder)
if err != nil {
return errors.Wrap(err, "failed to delete notifications")
}
return nil
}
func (s *SqlPostPersistentNotificationStore) DeleteByChannel(channelIds []string) error {
count := len(channelIds)
if count == 0 {
return nil
}
deleteAt := model.GetMillis()
builder := s.getQueryBuilder().
Update("PersistentNotifications").
Set("DeleteAt", deleteAt).
From("Posts")
builder = builder.Where(sq.And{
sq.Expr("Posts.Id = PersistentNotifications.PostId"),
sq.Eq{"Posts.ChannelId": channelIds},
})
_, err := s.GetMaster().ExecBuilder(builder)
if err != nil {
return errors.Wrapf(err, "failed to delete notifications for channels %s", channelIds)
}
return nil
}
func (s *SqlPostPersistentNotificationStore) DeleteByTeam(teamIds []string) error {
count := len(teamIds)
if count == 0 {
return nil
}
deleteAt := model.GetMillis()
builder := s.getQueryBuilder().
Update("PersistentNotifications").
Set("DeleteAt", deleteAt).
From("Posts, Channels")
builder = builder.Where(sq.And{
sq.Expr("Posts.Id = PersistentNotifications.PostId"),
sq.Expr("Posts.ChannelId = Channels.Id"),
sq.Eq{"Channels.TeamId": teamIds},
})
_, err := s.GetMaster().ExecBuilder(builder)
if err != nil {
return errors.Wrapf(err, "failed to delete notifications for teams %s", teamIds)
}
return nil
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestPostPersistentNotificationStore(t *testing.T) {
StoreTestWithSqlStore(t, storetest.TestPostPersistentNotificationStore)
}

View File

@@ -0,0 +1,195 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
type SqlPostPriorityStore struct {
*SqlStore
}
func newSqlPostPriorityStore(sqlStore *SqlStore) store.PostPriorityStore {
return &SqlPostPriorityStore{
SqlStore: sqlStore,
}
}
func (s *SqlPostPriorityStore) GetForPost(postId string) (*model.PostPriority, error) {
return s.GetForPostWithContext(request.EmptyContext(s.logger), postId)
}
func (s *SqlPostPriorityStore) GetForPostWithContext(rctx request.CTX, postId string) (*model.PostPriority, error) {
query := s.getQueryBuilder().
Select("PostId", "ChannelId", "Priority", "RequestedAck", "PersistentNotifications").
From("PostsPriority").
Where(sq.Eq{"PostId": postId})
var postPriority model.PostPriority
// Use DBXFromContext to respect master/replica context flag
err := s.DBXFromContext(rctx.Context()).GetBuilder(&postPriority, query)
if err != nil {
return nil, err
}
return &postPriority, nil
}
func (s *SqlPostPriorityStore) GetForPosts(postIds []string) ([]*model.PostPriority, error) {
var priority []*model.PostPriority
perPage := 200
for i := 0; i < len(postIds); i += perPage {
j := min(len(postIds), i+perPage)
query := s.getQueryBuilder().
Select("PostId", "ChannelId", "Priority", "RequestedAck", "PersistentNotifications").
From("PostsPriority").
Where(sq.Eq{"PostId": postIds[i:j]})
var priorityBatch []*model.PostPriority
err := s.GetReplica().SelectBuilder(&priorityBatch, query)
if err != nil {
return nil, err
}
priority = append(priority, priorityBatch...)
}
return priority, nil
}
func (s *SqlPostPriorityStore) Save(priority *model.PostPriority) (*model.PostPriority, error) {
tx, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
defer finalizeTransactionX(tx, &err)
// Delete existing priority
deleteQuery := s.getQueryBuilder().
Delete("PostsPriority").
Where(sq.Eq{"PostId": priority.PostId})
if _, err := tx.ExecBuilder(deleteQuery); err != nil {
return nil, errors.Wrap(err, "delete_existing_priority")
}
// Insert new priority
insertQuery := s.getQueryBuilder().
Insert("PostsPriority").
Columns("PostId", "ChannelId", "Priority", "RequestedAck", "PersistentNotifications").
Values(priority.PostId, priority.ChannelId, priority.Priority, priority.RequestedAck, priority.PersistentNotifications)
if _, err := tx.ExecBuilder(insertQuery); err != nil {
return nil, errors.Wrap(err, "insert_priority")
}
// Handle persistent notifications - always delete first, then insert if enabled
deletePersistentQuery := s.getQueryBuilder().
Delete("PersistentNotifications").
Where(sq.Eq{"PostId": priority.PostId})
if _, err := tx.ExecBuilder(deletePersistentQuery); err != nil {
return nil, errors.Wrap(err, "delete_persistent_notification")
}
if priority.PersistentNotifications != nil && *priority.PersistentNotifications {
insertPersistentQuery := s.getQueryBuilder().
Insert("PersistentNotifications").
Columns("PostId", "CreateAt", "LastSentAt", "DeleteAt", "SentCount").
Values(priority.PostId, model.GetMillis(), 0, 0, 0)
if _, err := tx.ExecBuilder(insertPersistentQuery); err != nil {
return nil, errors.Wrap(err, "insert_persistent_notification")
}
}
// Clear acknowledgements if not requested
if priority.RequestedAck == nil || !*priority.RequestedAck {
clearAckQuery := s.getQueryBuilder().
Update("PostAcknowledgements").
Set("AcknowledgedAt", 0).
Where(sq.Eq{"PostId": priority.PostId})
if _, err := tx.ExecBuilder(clearAckQuery); err != nil {
return nil, errors.Wrap(err, "clear_acknowledgements")
}
}
// Update the post's UpdateAt to trigger clients to refresh
updatePostQuery := s.getQueryBuilder().
Update("Posts").
Set("UpdateAt", model.GetMillis()).
Where(sq.Eq{"Id": priority.PostId})
if _, err := tx.ExecBuilder(updatePostQuery); err != nil {
return nil, errors.Wrap(err, "update_post")
}
if err := tx.Commit(); err != nil {
return nil, errors.Wrap(err, "commit_transaction")
}
return priority, nil
}
func (s *SqlPostPriorityStore) Delete(postId string) error {
tx, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "begin_transaction")
}
defer finalizeTransactionX(tx, &err)
// Delete from PostsPriority
deletePriorityQuery := s.getQueryBuilder().
Delete("PostsPriority").
Where(sq.Eq{"PostId": postId})
if _, err := tx.ExecBuilder(deletePriorityQuery); err != nil {
return errors.Wrap(err, "delete_priority")
}
// Delete from PersistentNotifications
deletePersistentQuery := s.getQueryBuilder().
Delete("PersistentNotifications").
Where(sq.Eq{"PostId": postId})
if _, err := tx.ExecBuilder(deletePersistentQuery); err != nil {
return errors.Wrap(err, "delete_persistent_notification")
}
// Clear acknowledgements
clearAckQuery := s.getQueryBuilder().
Update("PostAcknowledgements").
Set("AcknowledgedAt", 0).
Where(sq.Eq{"PostId": postId})
if _, err := tx.ExecBuilder(clearAckQuery); err != nil {
return errors.Wrap(err, "clear_acknowledgements")
}
// Update post
updatePostQuery := s.getQueryBuilder().
Update("Posts").
Set("UpdateAt", model.GetMillis()).
Where(sq.Eq{"Id": postId})
if _, err := tx.ExecBuilder(updatePostQuery); err != nil {
return errors.Wrap(err, "update_post")
}
if err := tx.Commit(); err != nil {
return errors.Wrap(err, "commit_transaction")
}
return nil
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestPostPriorityStore(t *testing.T) {
StoreTestWithSqlStore(t, storetest.TestPostPriorityStore)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/searchtest"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestPostStore(t *testing.T) {
StoreTestWithSqlStore(t, storetest.TestPostStore)
}
func TestSearchPostStore(t *testing.T) {
StoreTestWithSearchTestEngine(t, searchtest.TestSearchPostStore)
}

View File

@@ -0,0 +1,316 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
type SqlPreferenceStore struct {
*SqlStore
preferenceSelectQuery sq.SelectBuilder
}
func newSqlPreferenceStore(sqlStore *SqlStore) store.PreferenceStore {
s := &SqlPreferenceStore{SqlStore: sqlStore}
s.preferenceSelectQuery = s.getQueryBuilder().
Select("UserId", "Category", "Name", "Value").
From("Preferences")
return s
}
func (s SqlPreferenceStore) deleteUnusedFeatures() {
sql, args, err := s.getQueryBuilder().
Delete("Preferences").
Where(sq.Eq{"Category": model.PreferenceCategoryAdvancedSettings}).
Where(sq.Eq{"Value": "false"}).
Where(sq.Like{"Name": store.FeatureTogglePrefix + "%"}).ToSql()
if err != nil {
mlog.Warn("Could not build sql query to delete unused features", mlog.Err(err))
}
if _, err = s.GetMaster().Exec(sql, args...); err != nil {
mlog.Warn("Failed to delete unused features", mlog.Err(err))
}
}
func (s SqlPreferenceStore) Save(preferences model.Preferences) (err error) {
// wrap in a transaction so that if one fails, everything fails
transaction, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "begin_transaction")
}
defer finalizeTransactionX(transaction, &err)
for _, preference := range preferences {
if upsertErr := s.saveTx(transaction, &preference); upsertErr != nil {
return upsertErr
}
}
if err := transaction.Commit(); err != nil {
// don't need to rollback here since the transaction is already closed
return errors.Wrap(err, "commit_transaction")
}
return nil
}
func (s SqlPreferenceStore) save(transaction *sqlxTxWrapper, preference *model.Preference) error {
preference.PreUpdate()
if err := preference.IsValid(); err != nil {
return err
}
query := s.getQueryBuilder().
Insert("Preferences").
Columns("UserId", "Category", "Name", "Value").
Values(preference.UserId, preference.Category, preference.Name, preference.Value).
SuffixExpr(sq.Expr("ON CONFLICT (userid, category, name) DO UPDATE SET Value = ?", preference.Value))
queryString, args, err := query.ToSql()
if err != nil {
return errors.Wrap(err, "failed to generate sqlquery")
}
if _, err = transaction.Exec(queryString, args...); err != nil {
return errors.Wrap(err, "failed to save Preference")
}
return nil
}
func (s SqlPreferenceStore) saveTx(transaction *sqlxTxWrapper, preference *model.Preference) error {
preference.PreUpdate()
if err := preference.IsValid(); err != nil {
return err
}
query := s.getQueryBuilder().
Insert("Preferences").
Columns("UserId", "Category", "Name", "Value").
Values(preference.UserId, preference.Category, preference.Name, preference.Value).
SuffixExpr(sq.Expr("ON CONFLICT (userid, category, name) DO UPDATE SET Value = ?", preference.Value))
queryString, args, err := query.ToSql()
if err != nil {
return errors.Wrap(err, "failed to generate sqlquery")
}
if _, err = transaction.Exec(queryString, args...); err != nil {
return errors.Wrap(err, "failed to save Preference")
}
return nil
}
func (s SqlPreferenceStore) Get(userId string, category string, name string) (*model.Preference, error) {
var preference model.Preference
query := s.preferenceSelectQuery.
Where(sq.Eq{"UserId": userId}).
Where(sq.Eq{"Category": category}).
Where(sq.Eq{"Name": name})
if err := s.GetReplica().GetBuilder(&preference, query); err != nil {
return nil, errors.Wrapf(err, "failed to find Preference with userId=%s, category=%s, name=%s", userId, category, name)
}
return &preference, nil
}
func (s SqlPreferenceStore) GetCategoryAndName(category string, name string) (model.Preferences, error) {
var preferences model.Preferences
query := s.preferenceSelectQuery.
Where(sq.Eq{"Category": category}).
Where(sq.Eq{"Name": name})
if err := s.GetReplica().SelectBuilder(&preferences, query); err != nil {
return nil, errors.Wrapf(err, "failed to find Preferences with category=%s, name=%s", category, name)
}
return preferences, nil
}
func (s SqlPreferenceStore) GetCategory(userId string, category string) (model.Preferences, error) {
var preferences model.Preferences
query := s.preferenceSelectQuery.
Where(sq.Eq{"UserId": userId}).
Where(sq.Eq{"Category": category})
if err := s.GetReplica().SelectBuilder(&preferences, query); err != nil {
return nil, errors.Wrapf(err, "failed to find Preferences with userId=%s, category=%s", userId, category)
}
return preferences, nil
}
func (s SqlPreferenceStore) GetAll(userId string) (model.Preferences, error) {
var preferences model.Preferences
query := s.preferenceSelectQuery.
Where(sq.Eq{"UserId": userId})
if err := s.GetReplica().SelectBuilder(&preferences, query); err != nil {
return nil, errors.Wrapf(err, "failed to find Preferences with userId=%s", userId)
}
return preferences, nil
}
func (s SqlPreferenceStore) PermanentDeleteByUser(userId string) error {
sql, args, err := s.getQueryBuilder().
Delete("Preferences").
Where(sq.Eq{"UserId": userId}).ToSql()
if err != nil {
return errors.Wrap(err, "could not build sql query to get delete preference by user")
}
if _, err := s.GetMaster().Exec(sql, args...); err != nil {
return errors.Wrapf(err, "failed to delete Preference with userId=%s", userId)
}
return nil
}
func (s SqlPreferenceStore) Delete(userId, category, name string) error {
sql, args, err := s.getQueryBuilder().
Delete("Preferences").
Where(sq.Eq{"UserId": userId}).
Where(sq.Eq{"Category": category}).
Where(sq.Eq{"Name": name}).ToSql()
if err != nil {
return errors.Wrap(err, "could not build sql query to get delete preference")
}
if _, err = s.GetMaster().Exec(sql, args...); err != nil {
return errors.Wrapf(err, "failed to delete Preference with userId=%s, category=%s and name=%s", userId, category, name)
}
return nil
}
func (s SqlPreferenceStore) DeleteCategory(userId string, category string) error {
sql, args, err := s.getQueryBuilder().
Delete("Preferences").
Where(sq.Eq{"UserId": userId}).
Where(sq.Eq{"Category": category}).ToSql()
if err != nil {
return errors.Wrap(err, "could not build sql query to get delete preference by category")
}
if _, err = s.GetMaster().Exec(sql, args...); err != nil {
return errors.Wrapf(err, "failed to delete Preference with userId=%s and category=%s", userId, category)
}
return nil
}
func (s SqlPreferenceStore) DeleteCategoryAndName(category string, name string) error {
sql, args, err := s.getQueryBuilder().
Delete("Preferences").
Where(sq.Eq{"Name": name}).
Where(sq.Eq{"Category": category}).ToSql()
if err != nil {
return errors.Wrap(err, "could not build sql query to get delete preference by category and name")
}
if _, err = s.GetMaster().Exec(sql, args...); err != nil {
return errors.Wrapf(err, "failed to delete Preference with category=%s and name=%s", category, name)
}
return nil
}
// DeleteOrphanedRows removes entries from Preferences (flagged post) when a
// corresponding post no longer exists.
func (s *SqlPreferenceStore) DeleteOrphanedRows(limit int) (deleted int64, err error) {
const query = `
DELETE FROM Preferences WHERE ctid IN (
SELECT Preferences.ctid FROM Preferences
LEFT JOIN Posts ON Preferences.Name = Posts.Id
WHERE Posts.Id IS NULL AND Category = $1
LIMIT $2
)`
result, err := s.GetMaster().Exec(query, model.PreferenceCategoryFlaggedPost, limit)
if err != nil {
return
}
deleted, err = result.RowsAffected()
return
}
func (s SqlPreferenceStore) CleanupFlagsBatch(limit int64) (int64, error) {
if limit < 0 {
// uint64 does not throw an error, it overflows if it is negative.
// it is better to manually check here, or change the function type to uint64
return int64(0), errors.Errorf("Received a negative limit")
}
nameInQ, nameInArgs, err := sq.Select("Name").
FromSelect(
sq.Select("Preferences.Name").
From("Preferences").
LeftJoin("Posts ON Preferences.Name = Posts.Id").
Where(sq.Eq{"Preferences.Category": model.PreferenceCategoryFlaggedPost}).
Where(sq.Eq{"Posts.Id": nil}).
Limit(uint64(limit)),
"t").
ToSql()
if err != nil {
return int64(0), errors.Wrap(err, "could not build nested sql query to delete preference")
}
query, args, err := s.getQueryBuilder().Delete("Preferences").
Where(sq.Eq{"Category": model.PreferenceCategoryFlaggedPost}).
Where(sq.Expr("name IN ("+nameInQ+")", nameInArgs...)).
ToSql()
if err != nil {
return int64(0), errors.Wrap(err, "could not build sql query to delete preference")
}
sqlResult, err := s.GetMaster().Exec(query, args...)
if err != nil {
return int64(0), errors.Wrap(err, "failed to delete Preference")
}
rowsAffected, err := sqlResult.RowsAffected()
if err != nil {
return int64(0), errors.Wrap(err, "unable to get rows affected")
}
return rowsAffected, nil
}
// Delete preference for limit_visible_dms_gms where their value is greater than "40" or less than "1"
func (s SqlPreferenceStore) DeleteInvalidVisibleDmsGms() (int64, error) {
// We need to pad the value field with zeros when doing comparison's because the value is stored as a string.
// Having them the same length allows Postgres to compare them correctly.
whereClause := sq.And{
sq.Eq{"Category": model.PreferenceCategorySidebarSettings},
sq.Eq{"Name": model.PreferenceLimitVisibleDmsGms},
sq.Or{
sq.Gt{"SUBSTRING(CONCAT('000000000000000', Value), LENGTH(Value) + 1, 15)": "000000000000040"},
sq.Lt{"SUBSTRING(CONCAT('000000000000000', Value), LENGTH(Value) + 1, 15)": "000000000000001"},
},
}
subQuery := s.getQueryBuilder().
Select("UserId, Category, Name").
From("Preferences").
Where(whereClause).
Limit(100)
queryString, args, err := s.getQueryBuilder().
Delete("Preferences").
Where(sq.Expr("(userid, category, name) IN (?)", subQuery)).
ToSql()
if err != nil {
return int64(0), errors.Wrap(err, "could not build sql query to delete preference")
}
result, err := s.GetMaster().Exec(queryString, args...)
if err != nil {
return 0, errors.Wrap(err, "failed to delete Preference")
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return 0, errors.Wrap(err, "unable to get rows affected")
}
return rowsAffected, nil
}

View File

@@ -0,0 +1,84 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestPreferenceStore(t *testing.T) {
StoreTestWithSqlStore(t, storetest.TestPreferenceStore)
}
func TestDeleteUnusedFeatures(t *testing.T) {
StoreTest(t, func(t *testing.T, rctx request.CTX, ss store.Store) {
userId1 := model.NewId()
userId2 := model.NewId()
category := model.PreferenceCategoryAdvancedSettings
feature1 := "feature1"
feature2 := "feature2"
features := model.Preferences{
{
UserId: userId1,
Category: category,
Name: store.FeatureTogglePrefix + feature1,
Value: "true",
},
{
UserId: userId2,
Category: category,
Name: store.FeatureTogglePrefix + feature1,
Value: "false",
},
{
UserId: userId1,
Category: category,
Name: store.FeatureTogglePrefix + feature2,
Value: "false",
},
{
UserId: userId2,
Category: category,
Name: store.FeatureTogglePrefix + feature2,
Value: "true",
},
}
err := ss.Preference().Save(features)
require.NoError(t, err)
ss.Preference().(*SqlPreferenceStore).deleteUnusedFeatures()
//make sure features with value "false" have actually been deleted from the database
var val int64
if err := ss.Preference().(*SqlPreferenceStore).GetReplica().Get(&val, `SELECT COUNT(*)
FROM Preferences
WHERE Category = ?
AND Value = ?
AND Name LIKE '`+store.FeatureTogglePrefix+`%'`, model.PreferenceCategoryAdvancedSettings, "false"); err != nil {
require.NoError(t, err)
} else if val != 0 {
require.Fail(t, "Found %d features with value 'false', expected all to be deleted", val)
}
//
// make sure features with value "true" remain saved
if err := ss.Preference().(*SqlPreferenceStore).GetReplica().Get(&val, `SELECT COUNT(*)
FROM Preferences
WHERE Category = ?
AND Value = ?
AND Name LIKE '`+store.FeatureTogglePrefix+`%'`, model.PreferenceCategoryAdvancedSettings, "true"); err != nil {
require.NoError(t, err)
} else if val == 0 {
require.Fail(t, "Found %d features with value 'true', expected to find at least %d features", val, 2)
}
})
}

View File

@@ -0,0 +1,129 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"time"
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
type SqlProductNoticesStore struct {
*SqlStore
selectQuery sq.SelectBuilder
}
func newSqlProductNoticesStore(sqlStore *SqlStore) store.ProductNoticesStore {
s := &SqlProductNoticesStore{
SqlStore: sqlStore,
}
s.selectQuery = s.getQueryBuilder().
Select("UserId", "NoticeId", "Viewed", "Timestamp").
From("ProductNoticeViewState")
return s
}
func (s SqlProductNoticesStore) Clear(notices []string) error {
sql, args, err := s.getQueryBuilder().Delete("ProductNoticeViewState").Where(sq.Eq{"NoticeId": notices}).ToSql()
if err != nil {
return errors.Wrap(err, "product_notice_view_state_tosql")
}
if _, err := s.GetMaster().Exec(sql, args...); err != nil {
return errors.Wrap(err, "failed to delete records from ProductNoticeViewState")
}
return nil
}
func (s SqlProductNoticesStore) ClearOldNotices(currentNotices model.ProductNotices) error {
var notices []string
for _, currentNotice := range currentNotices {
notices = append(notices, currentNotice.ID)
}
sql, args, err := s.getQueryBuilder().Delete("ProductNoticeViewState").Where(sq.NotEq{"NoticeId": notices}).ToSql()
if err != nil {
return errors.Wrap(err, "product_notice_view_state_tosql")
}
if _, err := s.GetMaster().Exec(sql, args...); err != nil {
return errors.Wrapf(err, "failed to delete records from ProductNoticeViewState")
}
return nil
}
func (s SqlProductNoticesStore) View(userId string, notices []string) (err error) {
transaction, err := s.GetMaster().Begin()
if err != nil {
return errors.Wrap(err, "begin_transaction")
}
defer finalizeTransactionX(transaction, &err)
noticeStates := []model.ProductNoticeViewState{}
query := s.selectQuery.
Where(sq.Eq{"UserId": userId, "NoticeId": notices})
if err := transaction.SelectBuilder(&noticeStates, query); err != nil {
return errors.Wrapf(err, "failed to get ProductNoticeViewState with userId=%s", userId)
}
now := time.Now().UTC().Unix()
// update existing records
for i := range noticeStates {
noticeStates[i].Viewed += 1
noticeStates[i].Timestamp = now
if _, err := transaction.NamedExec(`UPDATE ProductNoticeViewState
SET Viewed=:Viewed, Timestamp=:Timestamp WHERE UserId=:UserId AND NoticeId=:NoticeId`, &noticeStates[i]); err != nil {
return errors.Wrapf(err, "failed to update ProductNoticeViewState")
}
}
// add new ones
haveNoticeState := func(n string) bool {
for _, ns := range noticeStates {
if ns.NoticeId == n {
return true
}
}
return false
}
for _, noticeId := range notices {
if !haveNoticeState(noticeId) {
productNoticeViewState := &model.ProductNoticeViewState{
UserId: userId,
NoticeId: noticeId,
Viewed: 1,
Timestamp: now,
}
if _, err := transaction.NamedExec(`INSERT INTO ProductNoticeViewState (UserId, NoticeId, Viewed, Timestamp)
VALUES (:UserId, :NoticeId, :Viewed, :Timestamp)`, productNoticeViewState); err != nil {
return errors.Wrapf(err, "failed to insert ProductNoticeViewState")
}
}
}
if err := transaction.Commit(); err != nil {
return errors.Wrap(err, "commit_transaction")
}
return nil
}
func (s SqlProductNoticesStore) GetViews(userId string) ([]model.ProductNoticeViewState, error) {
noticeStates := []model.ProductNoticeViewState{}
query := s.selectQuery.Where(sq.Eq{"UserId": userId})
if err := s.GetReplica().SelectBuilder(&noticeStates, query); err != nil {
return nil, errors.Wrapf(err, "failed to get ProductNoticeViewState with userId=%s", userId)
}
return noticeStates, nil
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestProductNoticesStore(t *testing.T) {
StoreTest(t, storetest.TestProductNoticesStore)
}

View File

@@ -0,0 +1,746 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"fmt"
"strings"
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
type SqlPropertyFieldStore struct {
*SqlStore
tableSelectQuery sq.SelectBuilder
}
func newPropertyFieldStore(sqlStore *SqlStore) store.PropertyFieldStore {
s := SqlPropertyFieldStore{SqlStore: sqlStore}
s.tableSelectQuery = s.getQueryBuilder().
Select("ID", "GroupID", "Name", "Type", "Attrs", "TargetID", "TargetType", "ObjectType", "Protected", "PermissionField", "PermissionValues", "PermissionOptions", "LinkedFieldID", "CreateAt", "UpdateAt", "DeleteAt", "COALESCE(CreatedBy, '') as CreatedBy", "COALESCE(UpdatedBy, '') as UpdatedBy").
From("PropertyFields")
return &s
}
func (s *SqlPropertyFieldStore) Create(field *model.PropertyField) (*model.PropertyField, error) {
if field.ID != "" {
return nil, store.NewErrInvalidInput("PropertyField", "id", field.ID)
}
field.PreSave()
if err := field.EnsureOptionIDs(); err != nil {
return nil, errors.Wrap(err, "property_field_create_ensure_option_ids")
}
if err := field.IsValid(); err != nil {
return nil, errors.Wrap(err, "property_field_create_isvalid")
}
builder := s.getQueryBuilder().
Insert("PropertyFields").
Columns("ID", "GroupID", "Name", "Type", "Attrs", "TargetID", "TargetType", "ObjectType", "Protected", "PermissionField", "PermissionValues", "PermissionOptions", "LinkedFieldID", "CreateAt", "UpdateAt", "DeleteAt", "CreatedBy", "UpdatedBy").
Values(field.ID, field.GroupID, field.Name, field.Type, field.Attrs, field.TargetID, field.TargetType, field.ObjectType, field.Protected, field.PermissionField, field.PermissionValues, field.PermissionOptions, field.LinkedFieldID, field.CreateAt, field.UpdateAt, field.DeleteAt, field.CreatedBy, field.UpdatedBy)
if _, err := s.GetMaster().ExecBuilder(builder); err != nil {
return nil, errors.Wrap(err, "property_field_create_insert")
}
return field, nil
}
func (s *SqlPropertyFieldStore) Get(rctx request.CTX, groupID, id string) (*model.PropertyField, error) {
builder := s.tableSelectQuery.Where(sq.Eq{"id": id})
if groupID != "" {
builder = builder.Where(sq.Eq{"GroupID": groupID})
}
var field model.PropertyField
if err := s.DBXFromContext(rctx.Context()).GetBuilder(&field, builder); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, store.NewErrNotFound("PropertyField", id)
}
return nil, errors.Wrap(err, "property_field_get_select")
}
return &field, nil
}
// GetFieldByName retrieves a single property field by group, target, and name,
// matching any object type.
//
// Deprecated: a (groupID, targetID, name) tuple is not unique when fields of
// different object types share a name within a group, in which case this
// returns an arbitrary match (the query has no ORDER BY/LIMIT). Use
// GetFieldByNameForObjectType for a deterministic result. Retained because it
// is exposed on the (stable) plugin API.
func (s *SqlPropertyFieldStore) GetFieldByName(rctx request.CTX, groupID, targetID, name string) (*model.PropertyField, error) {
return s.getFieldByName(rctx, s.fieldByNameQuery(groupID, targetID, name), name)
}
// GetFieldByNameForObjectType retrieves a single property field by group,
// target, object type, and name. objectType is matched exactly — including the
// empty string, which is itself a valid object type, not a match-any wildcard —
// so together with the typed unique index the result is deterministic.
func (s *SqlPropertyFieldStore) GetFieldByNameForObjectType(rctx request.CTX, groupID, targetID, objectType, name string) (*model.PropertyField, error) {
builder := s.fieldByNameQuery(groupID, targetID, name).Where(sq.Eq{"ObjectType": objectType})
return s.getFieldByName(rctx, builder, name)
}
func (s *SqlPropertyFieldStore) fieldByNameQuery(groupID, targetID, name string) sq.SelectBuilder {
return s.tableSelectQuery.
Where(sq.Eq{"GroupID": groupID}).
Where(sq.Eq{"TargetID": targetID}).
Where(sq.Eq{"Name": name}).
Where(sq.Eq{"DeleteAt": 0})
}
func (s *SqlPropertyFieldStore) getFieldByName(rctx request.CTX, builder sq.SelectBuilder, name string) (*model.PropertyField, error) {
var field model.PropertyField
if err := s.DBXFromContext(rctx.Context()).GetBuilder(&field, builder); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, store.NewErrNotFound("PropertyField", name)
}
return nil, errors.Wrap(err, "property_field_get_by_name_select")
}
return &field, nil
}
func (s *SqlPropertyFieldStore) GetMany(rctx request.CTX, groupID string, ids []string) ([]*model.PropertyField, error) {
builder := s.tableSelectQuery.Where(sq.Eq{"id": ids})
if groupID != "" {
builder = builder.Where(sq.Eq{"GroupID": groupID})
}
fields := []*model.PropertyField{}
if err := s.DBXFromContext(rctx.Context()).SelectBuilder(&fields, builder); err != nil {
return nil, errors.Wrap(err, "property_field_get_many_query")
}
if len(fields) < len(ids) {
return nil, store.NewErrResultsMismatch(len(fields), len(ids))
}
return fields, nil
}
func (s *SqlPropertyFieldStore) CountForGroup(groupID string, includeDeleted bool) (int64, error) {
var count int64
builder := s.getQueryBuilder().
Select("COUNT(id)").
From("PropertyFields").
Where(sq.Eq{"GroupID": groupID})
if !includeDeleted {
builder = builder.Where(sq.Eq{"DeleteAt": 0})
}
if err := s.GetReplica().GetBuilder(&count, builder); err != nil {
return int64(0), errors.Wrap(err, "failed to count Sessions")
}
return count, nil
}
func (s *SqlPropertyFieldStore) CountForGroupObjectType(groupID, objectType string, includeDeleted bool) (int64, error) {
var count int64
builder := s.getQueryBuilder().
Select("COUNT(id)").
From("PropertyFields").
Where(sq.Eq{"GroupID": groupID}).
Where(sq.Eq{"ObjectType": objectType})
if !includeDeleted {
builder = builder.Where(sq.Eq{"DeleteAt": 0})
}
if err := s.GetReplica().GetBuilder(&count, builder); err != nil {
return int64(0), errors.Wrap(err, "failed to count property fields for group and object type")
}
return count, nil
}
func (s *SqlPropertyFieldStore) CountForTarget(groupID, targetType, targetID string, includeDeleted bool) (int64, error) {
var count int64
builder := s.getQueryBuilder().
Select("COUNT(id)").
From("PropertyFields").
Where(sq.Eq{"GroupID": groupID}).
Where(sq.Eq{"TargetType": targetType}).
Where(sq.Eq{"TargetID": targetID})
if !includeDeleted {
builder = builder.Where(sq.Eq{"DeleteAt": 0})
}
if err := s.GetReplica().GetBuilder(&count, builder); err != nil {
return int64(0), errors.Wrap(err, "failed to count property fields for target")
}
return count, nil
}
func (s *SqlPropertyFieldStore) GetForGroup(rctx request.CTX, groupID string) ([]*model.PropertyField, error) {
builder := s.tableSelectQuery.
Where(sq.Eq{"GroupID": groupID}).
Where(sq.Eq{"DeleteAt": 0})
fields := []*model.PropertyField{}
if err := s.DBXFromContext(rctx.Context()).SelectBuilder(&fields, builder); err != nil {
return nil, errors.Wrap(err, "property_field_get_for_group_query")
}
return fields, nil
}
// SearchPropertyFields runs the PSAv2 field listing query.
//
// The store operates in two modes determined by opts.SinceUpdateAt:
//
// - Delta mode (SinceUpdateAt > 0): orders by UpdateAt ASC, Id ASC; paginates
// with the (UpdateAt, Id) cursor key; auto-includes soft-deleted rows. The
// DeleteAt filter is NOT applied in this mode.
// - Directory mode (SinceUpdateAt <= 0): orders by CreateAt ASC, Id ASC;
// paginates with the (CreateAt, Id) cursor key; honors opts.IncludeDeleted.
//
// The scope filter has two mutually exclusive shapes, enforced by
// opts.IsValid(): either we search through the hierarchy (channel or team and up,
// if ChannelID or TeamID are set) or we filter on a single target using
// TargetType/TargetIDs.
func (s *SqlPropertyFieldStore) SearchPropertyFields(opts model.PropertyFieldSearchOpts) ([]*model.PropertyField, error) {
if err := opts.IsValid(); err != nil {
return nil, fmt.Errorf("opts is invalid: %w", err)
}
if opts.PerPage < 1 {
return nil, errors.New("per page must be positive integer greater than zero")
}
deltaMode := opts.SinceUpdateAt > 0
builder := s.tableSelectQuery.Limit(uint64(opts.PerPage))
if deltaMode {
builder = builder.OrderBy("UpdateAt ASC, Id ASC")
} else {
builder = builder.OrderBy("CreateAt ASC, Id ASC")
}
if !opts.Cursor.IsEmpty() {
if deltaMode {
builder = builder.Where(sq.Or{
sq.Gt{"UpdateAt": opts.Cursor.UpdateAt},
sq.And{
sq.Eq{"UpdateAt": opts.Cursor.UpdateAt},
sq.Gt{"Id": opts.Cursor.PropertyFieldID},
},
})
} else {
builder = builder.Where(sq.Or{
sq.Gt{"CreateAt": opts.Cursor.CreateAt},
sq.And{
sq.Eq{"CreateAt": opts.Cursor.CreateAt},
sq.Gt{"Id": opts.Cursor.PropertyFieldID},
},
})
}
}
// Delta mode auto-includes tombstones; directory mode keeps the explicit
// IncludeDeleted opt-in.
if !deltaMode && !opts.IncludeDeleted {
builder = builder.Where(sq.Eq{"DeleteAt": 0})
}
if opts.GroupID != "" {
builder = builder.Where(sq.Eq{"GroupID": opts.GroupID})
}
// Prefer ObjectTypes (renders as IN); fall back to the deprecated
// single-value ObjectType for backwards compatibility.
if len(opts.ObjectTypes) > 0 {
builder = builder.Where(sq.Eq{"ObjectType": opts.ObjectTypes})
} else if opts.ObjectType != "" {
builder = builder.Where(sq.Eq{"ObjectType": opts.ObjectType})
}
// Four mutually exclusive scopes (enforced by opts.IsValid()):
// - Channel + team: OR{system, team=TeamID, channel=ChannelID}
// - Channel only (DM/GM, no team): OR{system, channel=ChannelID}
// - Team-only: OR{system, team=TeamID}
// - Single target: WHERE TargetType = ? and/or TargetID IN (?) — either
// filter may be applied independently for backwards compatibility.
switch {
case opts.ChannelID != "" && opts.TeamID != "":
builder = builder.Where(sq.Or{
sq.Eq{"TargetType": string(model.PropertyFieldTargetLevelSystem)},
sq.And{
sq.Eq{"TargetType": string(model.PropertyFieldTargetLevelTeam)},
sq.Eq{"TargetID": opts.TeamID},
},
sq.And{
sq.Eq{"TargetType": string(model.PropertyFieldTargetLevelChannel)},
sq.Eq{"TargetID": opts.ChannelID},
},
})
case opts.ChannelID != "":
// DM/GM channels have no parent team, so the hierarchy is just
// system → channel.
builder = builder.Where(sq.Or{
sq.Eq{"TargetType": string(model.PropertyFieldTargetLevelSystem)},
sq.And{
sq.Eq{"TargetType": string(model.PropertyFieldTargetLevelChannel)},
sq.Eq{"TargetID": opts.ChannelID},
},
})
case opts.TeamID != "":
builder = builder.Where(sq.Or{
sq.Eq{"TargetType": string(model.PropertyFieldTargetLevelSystem)},
sq.And{
sq.Eq{"TargetType": string(model.PropertyFieldTargetLevelTeam)},
sq.Eq{"TargetID": opts.TeamID},
},
})
default:
if opts.TargetType != "" {
builder = builder.Where(sq.Eq{"TargetType": opts.TargetType})
}
if len(opts.TargetIDs) > 0 {
builder = builder.Where(sq.Eq{"TargetID": opts.TargetIDs})
}
}
if opts.LinkedFieldID != "" {
builder = builder.Where(sq.Eq{"LinkedFieldID": opts.LinkedFieldID})
}
if deltaMode {
// Inclusive boundary so rows updated at exactly `since`
// are returned on the first page. The cursor clause above
// then disambiguates same-millisecond rows by Id across
// subsequent pages.
builder = builder.Where(sq.GtOrEq{"UpdateAt": opts.SinceUpdateAt})
}
fields := []*model.PropertyField{}
if err := s.GetReplica().SelectBuilder(&fields, builder); err != nil {
return nil, errors.Wrap(err, "property_field_search_query")
}
return fields, nil
}
func (s *SqlPropertyFieldStore) Update(groupID string, fields []*model.PropertyField, expectedUpdateAts map[string]int64) ([]*model.PropertyField, error) {
if len(fields) == 0 {
return nil, nil
}
transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "property_field_update_begin_transaction")
}
defer finalizeTransactionX(transaction, &err)
updateTime := model.GetMillis()
nameCase := sq.Case("id")
typeCase := sq.Case("id")
attrsCase := sq.Case("id")
targetIDCase := sq.Case("id")
targetTypeCase := sq.Case("id")
protectedCase := sq.Case("id")
permissionFieldCase := sq.Case("id")
permissionValuesCase := sq.Case("id")
permissionOptionsCase := sq.Case("id")
linkedFieldIDCase := sq.Case("id")
deleteAtCase := sq.Case("id")
updatedByCase := sq.Case("id")
ids := make([]string, len(fields))
for i, field := range fields {
field.UpdateAt = updateTime
if ensureErr := field.EnsureOptionIDs(); ensureErr != nil {
return nil, errors.Wrap(ensureErr, "property_field_update_ensure_option_ids")
}
if vErr := field.IsValid(); vErr != nil {
return nil, errors.Wrap(vErr, "property_field_update_isvalid")
}
ids[i] = field.ID
whenID := sq.Expr("?", field.ID)
nameCase = nameCase.When(whenID, sq.Expr("?::text", field.Name))
typeCase = typeCase.When(whenID, sq.Expr("?::property_field_type", field.Type))
attrsCase = attrsCase.When(whenID, sq.Expr("?::jsonb", field.Attrs))
targetIDCase = targetIDCase.When(whenID, sq.Expr("?::text", field.TargetID))
targetTypeCase = targetTypeCase.When(whenID, sq.Expr("?::text", field.TargetType))
protectedCase = protectedCase.When(whenID, sq.Expr("?::boolean", field.Protected))
permissionFieldCase = permissionFieldCase.When(whenID, sq.Expr("?::permission_level", field.PermissionField))
permissionValuesCase = permissionValuesCase.When(whenID, sq.Expr("?::permission_level", field.PermissionValues))
permissionOptionsCase = permissionOptionsCase.When(whenID, sq.Expr("?::permission_level", field.PermissionOptions))
linkedFieldIDCase = linkedFieldIDCase.When(whenID, sq.Expr("?", field.LinkedFieldID))
deleteAtCase = deleteAtCase.When(whenID, sq.Expr("?::bigint", field.DeleteAt))
updatedByCase = updatedByCase.When(whenID, sq.Expr("?::text", field.UpdatedBy))
}
builder := s.getQueryBuilder().
Update("PropertyFields").
Set("Name", nameCase).
Set("Type", typeCase).
Set("Attrs", attrsCase).
Set("TargetID", targetIDCase).
Set("TargetType", targetTypeCase).
Set("Protected", protectedCase).
Set("PermissionField", permissionFieldCase).
Set("PermissionValues", permissionValuesCase).
Set("PermissionOptions", permissionOptionsCase).
Set("LinkedFieldID", linkedFieldIDCase).
Set("UpdateAt", updateTime).
Set("DeleteAt", deleteAtCase).
Set("UpdatedBy", updatedByCase).
Where(sq.Eq{"id": ids})
if groupID != "" {
builder = builder.Where(sq.Eq{"GroupID": groupID})
}
// Optimistic concurrency: if expectedUpdateAts is provided, only update
// rows whose UpdateAt still matches the value read before validation.
// This closes the TOCTOU window between validation and the UPDATE.
if len(expectedUpdateAts) > 0 {
updateAtCase := sq.Case("id")
for _, id := range ids {
if expected, ok := expectedUpdateAts[id]; ok {
updateAtCase = updateAtCase.When(sq.Expr("?", id), sq.Expr("?::bigint", expected))
}
}
caseSql, caseArgs, caseErr := updateAtCase.ToSql()
if caseErr != nil {
return nil, errors.Wrap(caseErr, "property_field_update_build_update_at_check")
}
builder = builder.Where("UpdateAt = "+caseSql, caseArgs...)
}
result, err := transaction.ExecBuilder(builder)
if err != nil {
return nil, errors.Wrap(err, "property_field_update_exec")
}
count, err := result.RowsAffected()
if err != nil {
return nil, errors.Wrap(err, "property_field_update_rowsaffected")
}
if count != int64(len(fields)) {
if len(expectedUpdateAts) > 0 {
return nil, store.NewErrConflict("PropertyField", nil, "concurrent modification detected; retry the update")
}
return nil, errors.Errorf("failed to update, some property fields were not found, got %d of %d", count, len(fields))
}
// Propagate type and options from updated source fields to all their
// linked dependents. This self-joins PropertyFields: "source" is the
// row we just updated, "linked" is any row whose LinkedFieldID points
// to it. Only rows where type or options actually differ are touched,
// so this is a no-op when none of the updated fields have dependents.
//
// We build the query manually because squirrel doesn't support
// PostgreSQL's UPDATE ... FROM syntax, and use ExecRaw because the
// placeholders are already in $N format (Exec would try to rebind them).
//
// For ids = ["aaa", "bbb"] the args and SQL expand to:
// propagateArgs = [updateTime, "aaa", "bbb"] → $1, $2, $3
// SQL: ... WHERE source.ID IN ($2, $3) ... UpdateAt = $1
inPlaceholders := make([]string, len(ids))
propagateArgs := make([]any, 0, len(ids)+1)
propagateArgs = append(propagateArgs, updateTime)
for i, id := range ids {
inPlaceholders[i] = fmt.Sprintf("$%d", i+2)
propagateArgs = append(propagateArgs, id)
}
propagateSQL := fmt.Sprintf(`
UPDATE PropertyFields AS linked
SET Type = source.Type,
Attrs = jsonb_set(COALESCE(linked.Attrs, '{}'::jsonb), '{options}',
COALESCE(source.Attrs->'options', '[]'::jsonb)),
UpdateAt = $1
FROM PropertyFields AS source
WHERE source.ID IN (%s)
AND linked.LinkedFieldID = source.ID
AND linked.DeleteAt = 0
AND (linked.Type != source.Type
OR linked.Attrs->'options' IS DISTINCT FROM source.Attrs->'options')
`, strings.Join(inPlaceholders, ", "))
if _, execErr := transaction.ExecRaw(propagateSQL, propagateArgs...); execErr != nil {
return nil, errors.Wrap(execErr, "property_field_update_propagate")
}
// Retrieve propagated linked fields to include in the return value
selectBuilder := s.tableSelectQuery.
Where(sq.Eq{"LinkedFieldID": ids}).
Where(sq.Eq{"DeleteAt": 0}).
Where(sq.Eq{"UpdateAt": updateTime})
var propagatedFields []*model.PropertyField
if selectErr := transaction.SelectBuilder(&propagatedFields, selectBuilder); selectErr != nil {
return nil, errors.Wrap(selectErr, "property_field_update_select_propagated")
}
if err := transaction.Commit(); err != nil {
return nil, errors.Wrap(err, "property_field_update_commit_transaction")
}
return append(fields, propagatedFields...), nil
}
func (s *SqlPropertyFieldStore) Delete(groupID string, id string) error {
builder := s.getQueryBuilder().
Update("PropertyFields").
Set("DeleteAt", model.GetMillis()).
Where(sq.Eq{"id": id})
if groupID != "" {
builder = builder.Where(sq.Eq{"GroupID": groupID})
}
result, err := s.GetMaster().ExecBuilder(builder)
if err != nil {
return errors.Wrapf(err, "failed to delete property field with id: %s", id)
}
count, err := result.RowsAffected()
if err != nil {
return errors.Wrap(err, "property_field_delete_rowsaffected")
}
if count == 0 {
return store.NewErrNotFound("PropertyField", id)
}
return nil
}
// buildConflictSubquery creates a subquery to check for property conflicts at a given level.
// The excludeID is only added to the WHERE clause when non-empty.
// Uses Question placeholder format (?) for proper parameter merging when combining queries.
func (s *SqlPropertyFieldStore) buildConflictSubquery(level string, objectType, groupID, name, excludeID string) sq.SelectBuilder {
builder := sq.StatementBuilder.PlaceholderFormat(sq.Question).
Select(fmt.Sprintf("'%s'", level)).
From("PropertyFields").
Where(sq.Eq{"ObjectType": objectType}).
Where(sq.Eq{"GroupID": groupID}).
Where(sq.Eq{"TargetType": level}).
Where(sq.Eq{"Name": name}).
Where(sq.Eq{"DeleteAt": 0}).
Limit(1)
if excludeID != "" {
builder = builder.Where(sq.NotEq{"ID": excludeID})
}
return builder
}
// CheckPropertyNameConflict checks if a property field would conflict with
// existing properties at the same level or in the hierarchy. It should be called
// before creating or updating a property field to enforce uniqueness.
//
// Same-level uniqueness: two properties at the same TargetType (and same TargetID
// for team/channel) with the same Name, ObjectType, and GroupID conflict. This
// prevents duplicate names within the same scope (e.g., two templates named
// "Classification" at system level in the same group).
//
// The hierarchy additionally works as follows:
// - System-level properties (TargetType="system") conflict with any team or channel
// property with the same name in the same ObjectType and GroupID
// - Team-level properties (TargetType="team") conflict with system properties and
// channel properties within that team
// - Channel-level properties (TargetType="channel") conflict with system properties
// and the team property of the channel's team
//
// Returns the conflict level ("system", "team", or "channel") if a conflict exists,
// or an empty string if no conflict. Legacy properties (ObjectType="") skip the
// check entirely and rely on the database constraint for uniqueness.
//
// For channel-level properties, the method uses a subquery to look up the channel's
// TeamId, which handles DM channels naturally (they have empty TeamId).
//
// The excludeID parameter allows excluding a specific property field ID from the
// conflict check. This is useful when updating a property field, where the field
// being updated should not conflict with itself. Pass an empty string when creating
// new fields.
func (s *SqlPropertyFieldStore) CheckPropertyNameConflict(field *model.PropertyField, excludeID string) (model.PropertyFieldTargetLevel, error) {
// Legacy properties (PSAv1) use old uniqueness via DB constraint
if field.IsPSAv1() {
return "", nil
}
switch field.TargetType {
case string(model.PropertyFieldTargetLevelSystem):
return s.checkSystemLevelConflict(field, excludeID)
case string(model.PropertyFieldTargetLevelTeam):
return s.checkTeamLevelConflict(field, excludeID)
case string(model.PropertyFieldTargetLevelChannel):
return s.checkChannelLevelConflict(field, excludeID)
default:
// Unknown target type - let DB constraint handle
return "", nil
}
}
// checkSystemLevelConflict checks if a system-level property would conflict with
// another system-level property with the same name, or any team or channel property
// with the same name, in the same ObjectType and GroupID.
func (s *SqlPropertyFieldStore) checkSystemLevelConflict(field *model.PropertyField, excludeID string) (model.PropertyFieldTargetLevel, error) {
// Build same-level (system) subquery — catches duplicate names at the same scope
systemSubquery := s.buildConflictSubquery("system", field.ObjectType, field.GroupID, field.Name, excludeID)
systemSQL, systemArgs, err := systemSubquery.ToSql()
if err != nil {
return "", errors.Wrap(err, "property_field_check_conflict_system_system_sql")
}
// Build team subquery
teamSubquery := s.buildConflictSubquery("team", field.ObjectType, field.GroupID, field.Name, excludeID)
teamSQL, teamArgs, err := teamSubquery.ToSql()
if err != nil {
return "", errors.Wrap(err, "property_field_check_conflict_system_team_sql")
}
// Build channel subquery
channelSubquery := s.buildConflictSubquery("channel", field.ObjectType, field.GroupID, field.Name, excludeID)
channelSQL, channelArgs, err := channelSubquery.ToSql()
if err != nil {
return "", errors.Wrap(err, "property_field_check_conflict_system_channel_sql")
}
// Combine with COALESCE, use Rebind to convert ? placeholders to $1, $2, etc.
query := fmt.Sprintf("SELECT COALESCE((%s), (%s), (%s), '')", systemSQL, teamSQL, channelSQL)
args := append(systemArgs, teamArgs...)
args = append(args, channelArgs...)
var conflictLevel model.PropertyFieldTargetLevel
if err := s.GetMaster().Get(&conflictLevel, query, args...); err != nil {
return "", errors.Wrap(err, "property_field_check_conflict_system")
}
return conflictLevel, nil
}
// checkTeamLevelConflict checks if a team-level property would conflict with
// another team-level property with the same name and target, system properties,
// or channel properties within that team.
func (s *SqlPropertyFieldStore) checkTeamLevelConflict(field *model.PropertyField, excludeID string) (model.PropertyFieldTargetLevel, error) {
// Build same-level (team) subquery — same name within the same team target
teamSubquery := s.buildConflictSubquery("team", field.ObjectType, field.GroupID, field.Name, excludeID).
Where(sq.Eq{"TargetID": field.TargetID})
teamSQL, teamArgs, err := teamSubquery.ToSql()
if err != nil {
return "", errors.Wrap(err, "property_field_check_conflict_team_team_sql")
}
// Build system subquery
systemSubquery := s.buildConflictSubquery("system", field.ObjectType, field.GroupID, field.Name, excludeID)
systemSQL, systemArgs, err := systemSubquery.ToSql()
if err != nil {
return "", errors.Wrap(err, "property_field_check_conflict_team_system_sql")
}
// Build channel subquery (requires JOIN with Channels table)
// Use Question placeholder format for proper parameter merging
channelSubquery := sq.StatementBuilder.PlaceholderFormat(sq.Question).
Select("'channel'").
From("PropertyFields pf").
Join("Channels c ON c.Id = pf.TargetID AND c.TeamId = ?", field.TargetID).
Where(sq.Eq{"pf.ObjectType": field.ObjectType}).
Where(sq.Eq{"pf.GroupID": field.GroupID}).
Where(sq.Eq{"pf.TargetType": "channel"}).
Where(sq.Eq{"pf.Name": field.Name}).
Where(sq.Eq{"pf.DeleteAt": 0}).
Limit(1)
if excludeID != "" {
channelSubquery = channelSubquery.Where(sq.NotEq{"pf.ID": excludeID})
}
channelSQL, channelArgs, err := channelSubquery.ToSql()
if err != nil {
return "", errors.Wrap(err, "property_field_check_conflict_team_channel_sql")
}
// Combine with COALESCE, use Rebind to convert ? placeholders to $1, $2, etc.
query := fmt.Sprintf("SELECT COALESCE((%s), (%s), (%s), '')", teamSQL, systemSQL, channelSQL)
args := append(teamArgs, systemArgs...)
args = append(args, channelArgs...)
var conflictLevel model.PropertyFieldTargetLevel
if err := s.GetMaster().Get(&conflictLevel, query, args...); err != nil {
return "", errors.Wrap(err, "property_field_check_conflict_team")
}
return conflictLevel, nil
}
// checkChannelLevelConflict checks if a channel-level property would conflict with
// another channel-level property with the same name and target, system properties,
// or the team property of the channel's team.
// Uses a subquery to get TeamId from Channels table - handles DM channels naturally
// (DM channels have empty TeamId, so TargetID will be empty and won't match any team-level property).
func (s *SqlPropertyFieldStore) checkChannelLevelConflict(field *model.PropertyField, excludeID string) (model.PropertyFieldTargetLevel, error) {
// Build same-level (channel) subquery — same name within the same channel target
channelSubquery := s.buildConflictSubquery("channel", field.ObjectType, field.GroupID, field.Name, excludeID).
Where(sq.Eq{"TargetID": field.TargetID})
channelSQL, channelArgs, err := channelSubquery.ToSql()
if err != nil {
return "", errors.Wrap(err, "property_field_check_conflict_channel_channel_sql")
}
// Build system subquery
systemSubquery := s.buildConflictSubquery("system", field.ObjectType, field.GroupID, field.Name, excludeID)
systemSQL, systemArgs, err := systemSubquery.ToSql()
if err != nil {
return "", errors.Wrap(err, "property_field_check_conflict_channel_system_sql")
}
// Build team subquery (requires subquery to get TeamId from Channels)
// Use Question placeholder format for proper parameter merging
teamSubquery := s.buildConflictSubquery("team", field.ObjectType, field.GroupID, field.Name, excludeID).
Where(sq.Expr("TargetID = (SELECT TeamId FROM Channels WHERE Id = ?)", field.TargetID))
teamSQL, teamArgs, err := teamSubquery.ToSql()
if err != nil {
return "", errors.Wrap(err, "property_field_check_conflict_channel_team_sql")
}
// Combine with COALESCE, use Rebind to convert ? placeholders to $1, $2, etc.
query := fmt.Sprintf("SELECT COALESCE((%s), (%s), (%s), '')", channelSQL, systemSQL, teamSQL)
args := append(channelArgs, systemArgs...)
args = append(args, teamArgs...)
var conflictLevel model.PropertyFieldTargetLevel
if err := s.GetMaster().Get(&conflictLevel, query, args...); err != nil {
return "", errors.Wrap(err, "property_field_check_conflict_channel")
}
return conflictLevel, nil
}
func (s *SqlPropertyFieldStore) CountLinkedFields(fieldID string) (int64, error) {
var count int64
builder := s.getQueryBuilder().
Select("COUNT(id)").
From("PropertyFields").
Where(sq.Eq{"LinkedFieldID": fieldID}).
Where(sq.Eq{"DeleteAt": 0})
if err := s.GetMaster().GetBuilder(&count, builder); err != nil {
return 0, errors.Wrap(err, "property_field_count_linked_fields")
}
return count, nil
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestPropertyFieldStore(t *testing.T) {
StoreTestWithSqlStore(t, storetest.TestPropertyFieldStore)
}

View File

@@ -0,0 +1,109 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
var propertyGroupColumns = []string{"ID", "Name", "Version", "SchemaVersion"}
type SqlPropertyGroupStore struct {
*SqlStore
}
func newPropertyGroupStore(sqlStore *SqlStore) store.PropertyGroupStore {
return &SqlPropertyGroupStore{sqlStore}
}
func (s *SqlPropertyGroupStore) Register(group *model.PropertyGroup) (*model.PropertyGroup, error) {
if group == nil {
return nil, store.NewErrInvalidInput("PropertyGroup", "name", "")
}
group.PreSave()
if err := group.IsValid(); err != nil {
return nil, errors.Wrap(err, "property_group_register_isvalid")
}
builder := s.getQueryBuilder().
Insert("PropertyGroups").
Columns("ID", "Name", "Version", "SchemaVersion").
Values(group.ID, group.Name, group.Version, group.SchemaVersion)
builder = builder.SuffixExpr(sq.Expr("ON CONFLICT (Name) DO NOTHING"))
r, err := s.GetMaster().ExecBuilder(builder)
if err != nil {
return nil, errors.Wrap(err, "property_group_register_insert")
}
rowsAffected, err := r.RowsAffected()
if err != nil {
return nil, errors.Wrap(err, "property_group_register_rows_affected")
}
// there was a conflict during the insert, so we need to fetch the
// group to get its data
if rowsAffected == 0 {
return s.Get(group.Name)
}
return group, nil
}
func (s *SqlPropertyGroupStore) IncrementVersion(name string) error {
builder := s.getQueryBuilder().
Update("PropertyGroups").
Set("Version", sq.Expr("Version + 1")).
Where(sq.Eq{"Name": name})
_, err := s.GetMaster().ExecBuilder(builder)
if err != nil {
return errors.Wrap(err, "property_group_increment_version_exec")
}
return nil
}
func (s *SqlPropertyGroupStore) Get(name string) (*model.PropertyGroup, error) {
queryString, args, err := s.getQueryBuilder().
Select(propertyGroupColumns...).
From("PropertyGroups").
Where(sq.Eq{"Name": name}).
ToSql()
if err != nil {
return nil, errors.Wrap(err, "property_group_get_tosql")
}
var propertyGroup model.PropertyGroup
if err := s.GetReplica().Get(&propertyGroup, queryString, args...); err != nil {
return nil, store.NewErrNotFound("PropertyGroup", name)
}
return &propertyGroup, nil
}
func (s *SqlPropertyGroupStore) GetByID(id string) (*model.PropertyGroup, error) {
queryString, args, err := s.getQueryBuilder().
Select(propertyGroupColumns...).
From("PropertyGroups").
Where(sq.Eq{"ID": id}).
ToSql()
if err != nil {
return nil, errors.Wrap(err, "property_group_get_by_id_tosql")
}
var propertyGroup model.PropertyGroup
if err := s.GetReplica().Get(&propertyGroup, queryString, args...); err != nil {
return nil, store.NewErrNotFound("PropertyGroup", id)
}
return &propertyGroup, nil
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestPropertyGroupStore(t *testing.T) {
StoreTestWithSqlStore(t, storetest.TestPropertyGroupStore)
}

View File

@@ -0,0 +1,407 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"fmt"
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
type SqlPropertyValueStore struct {
*SqlStore
tableSelectQuery sq.SelectBuilder
}
func newPropertyValueStore(sqlStore *SqlStore) store.PropertyValueStore {
s := SqlPropertyValueStore{SqlStore: sqlStore}
s.tableSelectQuery = s.getQueryBuilder().
Select("ID", "TargetID", "TargetType", "GroupID", "FieldID", "Value", "CreateAt", "UpdateAt", "DeleteAt", "COALESCE(CreatedBy, '') as CreatedBy", "COALESCE(UpdatedBy, '') as UpdatedBy").
From("PropertyValues")
return &s
}
func (s *SqlPropertyValueStore) Create(value *model.PropertyValue) (*model.PropertyValue, error) {
if value.ID != "" {
return nil, store.NewErrInvalidInput("PropertyValue", "id", value.ID)
}
value.PreSave()
if err := value.IsValid(); err != nil {
return nil, errors.Wrap(err, "property_value_create_isvalid")
}
valueJSON := value.Value
if s.IsBinaryParamEnabled() {
valueJSON = AppendBinaryFlag(valueJSON)
}
builder := s.getQueryBuilder().
Insert("PropertyValues").
Columns("ID", "TargetID", "TargetType", "GroupID", "FieldID", "Value", "CreateAt", "UpdateAt", "DeleteAt", "CreatedBy", "UpdatedBy").
Values(value.ID, value.TargetID, value.TargetType, value.GroupID, value.FieldID, valueJSON, value.CreateAt, value.UpdateAt, value.DeleteAt, value.CreatedBy, value.UpdatedBy)
if _, err := s.GetMaster().ExecBuilder(builder); err != nil {
return nil, errors.Wrap(err, "property_value_create_insert")
}
return value, nil
}
func (s *SqlPropertyValueStore) CreateMany(values []*model.PropertyValue) ([]*model.PropertyValue, error) {
if len(values) == 0 {
return nil, nil
}
transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "property_value_create_many_begin_transaction")
}
defer finalizeTransactionX(transaction, &err)
for _, value := range values {
value.PreSave()
if err := value.IsValid(); err != nil {
return nil, errors.Wrap(err, "property_value_create_many_isvalid")
}
valueJSON := value.Value
if s.IsBinaryParamEnabled() {
valueJSON = AppendBinaryFlag(valueJSON)
}
builder := s.getQueryBuilder().
Insert("PropertyValues").
Columns("ID", "TargetID", "TargetType", "GroupID", "FieldID", "Value", "CreateAt", "UpdateAt", "DeleteAt", "CreatedBy", "UpdatedBy").
Values(value.ID, value.TargetID, value.TargetType, value.GroupID, value.FieldID, valueJSON, value.CreateAt, value.UpdateAt, value.DeleteAt, value.CreatedBy, value.UpdatedBy)
if _, err := transaction.ExecBuilder(builder); err != nil {
return nil, errors.Wrap(err, "property_value_create_many_exec")
}
}
if err := transaction.Commit(); err != nil {
return nil, errors.Wrap(err, "property_value_create_many_commit_transaction")
}
return values, nil
}
func (s *SqlPropertyValueStore) Get(groupID, id string) (*model.PropertyValue, error) {
builder := s.tableSelectQuery.Where(sq.Eq{"id": id})
if groupID != "" {
builder = builder.Where(sq.Eq{"GroupID": groupID})
}
var value model.PropertyValue
if err := s.GetReplica().GetBuilder(&value, builder); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, store.NewErrNotFound("PropertyValue", id)
}
return nil, errors.Wrap(err, "property_value_get_select")
}
return &value, nil
}
func (s *SqlPropertyValueStore) GetMany(groupID string, ids []string) ([]*model.PropertyValue, error) {
builder := s.tableSelectQuery.Where(sq.Eq{"id": ids})
if groupID != "" {
builder = builder.Where(sq.Eq{"GroupID": groupID})
}
var values []*model.PropertyValue
if err := s.GetReplica().SelectBuilder(&values, builder); err != nil {
return nil, errors.Wrap(err, "property_value_get_many_query")
}
if len(values) < len(ids) {
return nil, store.NewErrResultsMismatch(len(values), len(ids))
}
return values, nil
}
func (s *SqlPropertyValueStore) SearchPropertyValues(opts model.PropertyValueSearchOpts) ([]*model.PropertyValue, error) {
if err := opts.IsValid(); err != nil {
return nil, fmt.Errorf("opts is invalid: %w", err)
}
if opts.PerPage < 1 {
return nil, errors.New("per page must be positive integer greater than zero")
}
deltaMode := opts.SinceUpdateAt > 0
builder := s.tableSelectQuery.Limit(uint64(opts.PerPage))
if deltaMode {
builder = builder.OrderBy("UpdateAt ASC, Id ASC")
} else {
builder = builder.OrderBy("CreateAt ASC, Id ASC")
}
if !opts.Cursor.IsEmpty() {
if deltaMode {
builder = builder.Where(sq.Or{
sq.Gt{"UpdateAt": opts.Cursor.UpdateAt},
sq.And{
sq.Eq{"UpdateAt": opts.Cursor.UpdateAt},
sq.Gt{"Id": opts.Cursor.PropertyValueID},
},
})
} else {
builder = builder.Where(sq.Or{
sq.Gt{"CreateAt": opts.Cursor.CreateAt},
sq.And{
sq.Eq{"CreateAt": opts.Cursor.CreateAt},
sq.Gt{"Id": opts.Cursor.PropertyValueID},
},
})
}
}
if !deltaMode && !opts.IncludeDeleted {
builder = builder.Where(sq.Eq{"DeleteAt": 0})
}
if opts.GroupID != "" {
builder = builder.Where(sq.Eq{"GroupID": opts.GroupID})
}
if opts.TargetType != "" {
builder = builder.Where(sq.Eq{"TargetType": opts.TargetType})
}
if len(opts.TargetIDs) > 0 {
builder = builder.Where(sq.Eq{"TargetID": opts.TargetIDs})
}
if opts.FieldID != "" {
builder = builder.Where(sq.Eq{"FieldID": opts.FieldID})
}
if deltaMode {
// Inclusive boundary so rows updated at exactly `since` are
// returned on the first page. The cursor clause above then
// disambiguates same-millisecond rows by Id across pages.
builder = builder.Where(sq.GtOrEq{"UpdateAt": opts.SinceUpdateAt})
}
if opts.Value != nil {
builder = builder.Where(sq.Eq{"Value": string(opts.Value)})
}
var values []*model.PropertyValue
if err := s.GetReplica().SelectBuilder(&values, builder); err != nil {
return nil, errors.Wrap(err, "property_value_search_query")
}
return values, nil
}
func (s *SqlPropertyValueStore) Update(groupID string, values []*model.PropertyValue) (_ []*model.PropertyValue, err error) {
if len(values) == 0 {
return nil, nil
}
transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "property_value_update_begin_transaction")
}
defer finalizeTransactionX(transaction, &err)
updateTime := model.GetMillis()
valueCase := sq.Case("id")
deleteAtCase := sq.Case("id")
updatedByCase := sq.Case("id")
ids := make([]string, len(values))
for i, value := range values {
value.UpdateAt = updateTime
if vErr := value.IsValid(); vErr != nil {
return nil, errors.Wrap(vErr, "property_value_update_isvalid")
}
ids[i] = value.ID
valueJSON := value.Value
if s.IsBinaryParamEnabled() {
valueJSON = AppendBinaryFlag(valueJSON)
}
valueCase = valueCase.When(sq.Expr("?", value.ID), sq.Expr("?::jsonb", valueJSON))
deleteAtCase = deleteAtCase.When(sq.Expr("?", value.ID), sq.Expr("?::bigint", value.DeleteAt))
updatedByCase = updatedByCase.When(sq.Expr("?", value.ID), sq.Expr("?::text", value.UpdatedBy))
}
builder := s.getQueryBuilder().
Update("PropertyValues").
Set("Value", valueCase).
Set("DeleteAt", deleteAtCase).
Set("UpdateAt", updateTime).
Set("UpdatedBy", updatedByCase).
Where(sq.Eq{"id": ids})
if groupID != "" {
builder = builder.Where(sq.Eq{"GroupID": groupID})
}
result, err := transaction.ExecBuilder(builder)
if err != nil {
return nil, errors.Wrap(err, "property_value_update_exec")
}
count, err := result.RowsAffected()
if err != nil {
return nil, errors.Wrap(err, "property_value_update_rowsaffected")
}
if count != int64(len(values)) {
return nil, errors.Errorf("failed to update, some property values were not found, got %d of %d", count, len(values))
}
if err := transaction.Commit(); err != nil {
return nil, errors.Wrap(err, "property_value_update_commit_transaction")
}
return values, nil
}
func (s *SqlPropertyValueStore) Upsert(values []*model.PropertyValue) (_ []*model.PropertyValue, err error) {
if len(values) == 0 {
return nil, nil
}
transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "property_value_upsert_begin_transaction")
}
defer finalizeTransactionX(transaction, &err)
updatedValues := make([]*model.PropertyValue, len(values))
updateTime := model.GetMillis()
for i, value := range values {
// Pin CreateAt to updateTime so PreSave does not capture a later
// GetMillis() — keeping CreateAt == UpdateAt on insert.
if value.CreateAt == 0 {
value.CreateAt = updateTime
}
value.PreSave()
value.UpdateAt = updateTime
if err := value.IsValid(); err != nil {
return nil, errors.Wrap(err, "property_value_upsert_isvalid")
}
valueJSON := value.Value
if s.IsBinaryParamEnabled() {
valueJSON = AppendBinaryFlag(valueJSON)
}
builder := s.getQueryBuilder().
Insert("PropertyValues").
Columns("ID", "TargetID", "TargetType", "GroupID", "FieldID", "Value", "CreateAt", "UpdateAt", "DeleteAt", "CreatedBy", "UpdatedBy").
Values(value.ID, value.TargetID, value.TargetType, value.GroupID, value.FieldID, valueJSON, value.CreateAt, value.UpdateAt, value.DeleteAt, value.CreatedBy, value.UpdatedBy)
builder = builder.SuffixExpr(sq.Expr(
"ON CONFLICT (GroupID, TargetID, FieldID) WHERE DeleteAt = 0 DO UPDATE SET Value = ?, UpdateAt = ?, DeleteAt = ?, UpdatedBy = ? RETURNING ID, TargetID, TargetType, GroupID, FieldID, Value, CreateAt, UpdateAt, DeleteAt, COALESCE(CreatedBy, '') as CreatedBy, COALESCE(UpdatedBy, '') as UpdatedBy",
valueJSON,
value.UpdateAt,
0,
value.UpdatedBy,
))
var values []*model.PropertyValue
if err := transaction.SelectBuilder(&values, builder); err != nil {
return nil, errors.Wrapf(err, "failed to upsert property value with id: %s", value.ID)
}
if len(values) != 1 {
return nil, errors.New("property_value_upsert_select_length")
}
updatedValues[i] = values[0]
}
if err := transaction.Commit(); err != nil {
return nil, errors.Wrap(err, "property_value_upsert_commit")
}
return updatedValues, nil
}
func (s *SqlPropertyValueStore) Delete(groupID string, id string) error {
builder := s.getQueryBuilder().
Update("PropertyValues").
Set("DeleteAt", model.GetMillis()).
Where(sq.Eq{"id": id})
if groupID != "" {
builder = builder.Where(sq.Eq{"GroupID": groupID})
}
result, err := s.GetMaster().ExecBuilder(builder)
if err != nil {
return errors.Wrapf(err, "failed to delete property value with id: %s", id)
}
count, err := result.RowsAffected()
if err != nil {
return errors.Wrap(err, "property_value_delete_rowsaffected")
}
if count == 0 {
return store.NewErrNotFound("PropertyValue", id)
}
return nil
}
func (s *SqlPropertyValueStore) DeleteForField(groupID, fieldID string) error {
builder := s.getQueryBuilder().
Update("PropertyValues").
Set("DeleteAt", model.GetMillis()).
Where(sq.Eq{"FieldID": fieldID})
if groupID != "" {
builder = builder.Where(sq.Eq{"GroupID": groupID})
}
if _, err := s.GetMaster().ExecBuilder(builder); err != nil {
return errors.Wrap(err, "property_value_delete_for_field_exec")
}
return nil
}
func (s *SqlPropertyValueStore) DeleteForTarget(groupID string, targetType string, targetID string) error {
if targetType == "" || targetID == "" {
return store.NewErrInvalidInput("PropertyValue", "target", "type or id empty")
}
builder := s.getQueryBuilder().
Delete("PropertyValues").
Where(sq.Eq{
"TargetType": targetType,
"TargetID": targetID,
})
if groupID != "" {
builder = builder.Where(sq.Eq{"GroupID": groupID})
}
if _, err := s.GetMaster().ExecBuilder(builder); err != nil {
return errors.Wrap(err, "property_value_delete_for_target_exec")
}
return nil
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestPropertyValueStore(t *testing.T) {
StoreTestWithSqlStore(t, storetest.TestPropertyValueStore)
}

View File

@@ -0,0 +1,400 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"fmt"
"time"
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/pkg/errors"
)
type SqlReactionStore struct {
*SqlStore
}
func newSqlReactionStore(sqlStore *SqlStore) store.ReactionStore {
return &SqlReactionStore{sqlStore}
}
func (s *SqlReactionStore) Save(reaction *model.Reaction) (re *model.Reaction, err error) {
reaction.PreSave()
if err := reaction.IsValid(); err != nil {
return nil, err
}
transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
defer finalizeTransactionX(transaction, &err)
if reaction.ChannelId == "" {
// get channelId, if not already populated
var channelIds []string
query := "SELECT ChannelId from Posts where Id = ?"
err = transaction.Select(&channelIds, query, reaction.PostId)
if err != nil {
return nil, errors.Wrap(err, "failed while getting channelId from Posts")
}
if len(channelIds) == 0 {
return nil, store.NewErrNotFound("Post", reaction.PostId)
}
reaction.ChannelId = channelIds[0]
}
err = s.saveReactionAndUpdatePost(transaction, reaction)
if err != nil {
// We don't consider duplicated save calls as an error
if !IsUniqueConstraintError(err, []string{"reactions_pkey", "PRIMARY"}) {
return nil, errors.Wrap(err, "failed while saving reaction or updating post")
}
} else {
if err := transaction.Commit(); err != nil {
return nil, errors.Wrap(err, "commit_transaction")
}
}
return reaction, nil
}
func (s *SqlReactionStore) Delete(reaction *model.Reaction) (re *model.Reaction, err error) {
reaction.PreUpdate()
transaction, err := s.GetMaster().Begin()
if err != nil {
return nil, errors.Wrap(err, "begin_transaction")
}
defer finalizeTransactionX(transaction, &err)
if err := deleteReactionAndUpdatePost(transaction, reaction); err != nil {
return nil, errors.Wrap(err, "deleteReactionAndUpdatePost")
}
if err := transaction.Commit(); err != nil {
return nil, errors.Wrap(err, "commit_transaction")
}
return reaction, nil
}
// GetForPost returns all reactions associated with `postId` that are not deleted.
func (s *SqlReactionStore) GetForPost(postId string, allowFromCache bool) ([]*model.Reaction, error) {
builder := s.getQueryBuilder().
Select("UserId", "PostId", "EmojiName", "CreateAt", "COALESCE(UpdateAt, CreateAt) As UpdateAt",
"COALESCE(DeleteAt, 0) As DeleteAt", "RemoteId", "ChannelId").
From("Reactions").
Where(sq.Eq{"PostId": postId}).
Where(sq.Eq{"COALESCE(DeleteAt, 0)": 0}).
OrderBy("CreateAt")
var reactions []*model.Reaction
if err := s.GetReplica().SelectBuilder(&reactions, builder); err != nil {
return nil, errors.Wrapf(err, "failed to get Reactions with postId=%s", postId)
}
return reactions, nil
}
func (s *SqlReactionStore) ExistsOnPost(postId string, emojiName string) (bool, error) {
query := s.getQueryBuilder().
Select("1").
From("Reactions").
Where(sq.Eq{"PostId": postId}).
Where(sq.Eq{"EmojiName": emojiName}).
Where(sq.Eq{"COALESCE(DeleteAt, 0)": 0})
var hasRows bool
if err := s.GetReplica().GetBuilder(&hasRows, query); err != nil {
if err == sql.ErrNoRows {
return false, nil
}
return false, errors.Wrap(err, "failed to check for existing reaction")
}
return hasRows, nil
}
// GetForPostSince returns all reactions associated with `postId` updated after `since`.
func (s *SqlReactionStore) GetForPostSince(postId string, since int64, excludeRemoteId string, inclDeleted bool) ([]*model.Reaction, error) {
query := s.getQueryBuilder().
Select("UserId", "PostId", "EmojiName", "CreateAt", "COALESCE(UpdateAt, CreateAt) As UpdateAt",
"COALESCE(DeleteAt, 0) As DeleteAt", "RemoteId").
From("Reactions").
Where(sq.Eq{"PostId": postId}).
Where(sq.Gt{"UpdateAt": since}).
OrderBy("CreateAt")
if excludeRemoteId != "" {
query = query.Where(sq.NotEq{"COALESCE(RemoteId, '')": excludeRemoteId})
}
if !inclDeleted {
query = query.Where(sq.Eq{"COALESCE(DeleteAt, 0)": 0})
}
var reactions []*model.Reaction
if err := s.GetReplica().SelectBuilder(&reactions, query); err != nil {
return nil, errors.Wrapf(err, "failed to find reactions")
}
return reactions, nil
}
func (s *SqlReactionStore) GetUniqueCountForPost(postId string) (int, error) {
query := s.getQueryBuilder().
Select("COUNT(DISTINCT EmojiName)").
From("Reactions").
Where(sq.Eq{"PostId": postId}).
Where(sq.Eq{"DeleteAt": 0})
var count int64
err := s.GetReplica().GetBuilder(&count, query)
if err != nil {
return 0, errors.Wrap(err, "failed to count Reactions")
}
return int(count), nil
}
func (s *SqlReactionStore) GetSingle(userID, postID, remoteID, emojiName string) (*model.Reaction, error) {
query := s.getQueryBuilder().
Select("UserId", "PostId", "EmojiName", "CreateAt",
"COALESCE(UpdateAt, CreateAt) As UpdateAt", "COALESCE(DeleteAt, 0) As DeleteAt",
"RemoteId", "ChannelId").
From("Reactions").
Where(sq.Eq{"UserId": userID}).
Where(sq.Eq{"PostId": postID}).
Where(sq.Eq{"COALESCE(RemoteId, '')": remoteID}).
Where(sq.Eq{"EmojiName": emojiName})
var reactions []*model.Reaction
if err := s.GetReplica().SelectBuilder(&reactions, query); err != nil {
return nil, errors.Wrapf(err, "failed to find reaction")
}
if len(reactions) == 0 {
return nil, store.NewErrNotFound("Reaction", fmt.Sprintf("user_id=%s, post_id=%s, remote_id=%s, emoji_name=%s",
userID, postID, remoteID, emojiName))
}
return reactions[0], nil
}
func (s *SqlReactionStore) DeleteAllWithEmojiName(rctx request.CTX, emojiName string) error {
var reactions []*model.Reaction
now := model.GetMillis()
if err := s.GetReplica().Select(&reactions,
`SELECT
UserId,
PostId,
EmojiName,
CreateAt,
COALESCE(UpdateAt, CreateAt) As UpdateAt,
COALESCE(DeleteAt, 0) As DeleteAt,
RemoteId
FROM
Reactions
WHERE
EmojiName = ? AND COALESCE(DeleteAt, 0) = 0`, emojiName); err != nil {
return errors.Wrapf(err, "failed to get Reactions with emojiName=%s", emojiName)
}
_, err := s.GetMaster().Exec(
`UPDATE
Reactions
SET
UpdateAt = ?, DeleteAt = ?
WHERE
EmojiName = ? AND COALESCE(DeleteAt, 0) = 0`, now, now, emojiName)
if err != nil {
return errors.Wrapf(err, "failed to delete Reactions with emojiName=%s", emojiName)
}
for _, reaction := range reactions {
_, err := s.GetMaster().Exec(UpdatePostHasReactionsOnDeleteQuery, now, reaction.PostId, reaction.PostId)
if err != nil {
rctx.Logger().Warn("Unable to update Post.HasReactions while removing reactions",
mlog.String("post_id", reaction.PostId),
mlog.Err(err))
}
}
return nil
}
func (s *SqlReactionStore) permanentDeleteReactions(userId string) ([]string, error) {
txn, err := s.GetMaster().Begin()
if err != nil {
return nil, err
}
defer finalizeTransactionX(txn, &err)
postIds := []string{}
err = txn.Select(&postIds, "SELECT PostId FROM Reactions WHERE UserId = ?", userId)
if err != nil {
return nil, errors.Wrapf(err, "failed to get Reactions with userId=%s", userId)
}
query := s.getQueryBuilder().
Delete("Reactions").
Where(sq.And{
sq.Eq{"PostId": postIds},
sq.Eq{"UserId": userId},
})
_, err = txn.ExecBuilder(query)
if err != nil {
return nil, errors.Wrapf(err, "failed to delete reactions with userId=%s", userId)
}
if err = txn.Commit(); err != nil {
return nil, err
}
return postIds, nil
}
func (s SqlReactionStore) PermanentDeleteByUser(rctx request.CTX, userId string) error {
now := model.GetMillis()
postIds, err := s.permanentDeleteReactions(userId)
if err != nil {
return err
}
transaction, err := s.GetMaster().Begin()
if err != nil {
return err
}
defer finalizeTransactionX(transaction, &err)
for _, postId := range postIds {
_, err = transaction.Exec(UpdatePostHasReactionsOnDeleteQuery, now, postId, postId)
if err != nil {
rctx.Logger().Warn("Unable to update Post.HasReactions while removing reactions",
mlog.String("post_id", postId),
mlog.Err(err))
}
time.Sleep(10 * time.Millisecond)
}
if err = transaction.Commit(); err != nil {
return err
}
return nil
}
func (s *SqlReactionStore) DeleteOrphanedRowsByIds(r *model.RetentionIdsForDeletion) (int64, error) {
txn, err := s.GetMaster().Begin()
if err != nil {
return 0, err
}
defer finalizeTransactionX(txn, &err)
query := s.getQueryBuilder().
Delete("Reactions").
Where(
sq.Eq{"PostId": r.Ids},
)
sqlResult, err := txn.ExecBuilder(query)
if err != nil {
return 0, errors.Wrapf(err, "failed to delete orphaned reactions with RetentionIdsForDeletion Id=%s", r.Id)
}
err = deleteFromRetentionIdsTx(txn, r.Id)
if err != nil {
return 0, err
}
if err = txn.Commit(); err != nil {
return 0, err
}
rowsAffected, err := sqlResult.RowsAffected()
if err != nil {
return 0, errors.Wrap(err, "unable to retrieve rows affected")
}
return rowsAffected, nil
}
func (s *SqlReactionStore) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) {
query := "DELETE from Reactions WHERE CreateAt = any (array (SELECT CreateAt FROM Reactions WHERE CreateAt < ? LIMIT ?))"
sqlResult, err := s.GetMaster().Exec(query, endTime, limit)
if err != nil {
return 0, errors.Wrap(err, "failed to delete Reactions")
}
rowsAffected, err := sqlResult.RowsAffected()
if err != nil {
return 0, errors.Wrap(err, "unable to get rows affected for deleted Reactions")
}
return rowsAffected, nil
}
func (s *SqlReactionStore) saveReactionAndUpdatePost(transaction *sqlxTxWrapper, reaction *model.Reaction) error {
reaction.DeleteAt = 0
if _, err := transaction.NamedExec(
`INSERT INTO
Reactions
(UserId, PostId, EmojiName, CreateAt, UpdateAt, DeleteAt, RemoteId, ChannelId)
VALUES
(:UserId, :PostId, :EmojiName, :CreateAt, :UpdateAt, :DeleteAt, :RemoteId, :ChannelId)
ON CONFLICT (UserId, PostId, EmojiName)
DO UPDATE SET UpdateAt = :UpdateAt, DeleteAt = :DeleteAt, RemoteId = :RemoteId, ChannelId = :ChannelId`, reaction); err != nil {
return err
}
return updatePostForReactionsOnInsert(transaction, reaction.PostId)
}
func deleteReactionAndUpdatePost(transaction *sqlxTxWrapper, reaction *model.Reaction) error {
if _, err := transaction.Exec(
`UPDATE
Reactions
SET
UpdateAt = ?, DeleteAt = ?, RemoteId = ?
WHERE
PostId = ? AND
UserId = ? AND
EmojiName = ?`, reaction.UpdateAt, reaction.UpdateAt, reaction.RemoteId, reaction.PostId, reaction.UserId, reaction.EmojiName); err != nil {
return err
}
return updatePostForReactionsOnDelete(transaction, reaction.PostId)
}
const (
UpdatePostHasReactionsOnDeleteQuery = `UPDATE
Posts
SET
UpdateAt = ?,
HasReactions = (SELECT count(0) > 0 FROM Reactions WHERE PostId = ? AND COALESCE(DeleteAt, 0) = 0)
WHERE
Id = ?`
)
func updatePostForReactionsOnDelete(transaction *sqlxTxWrapper, postId string) error {
updateAt := model.GetMillis()
_, err := transaction.Exec(UpdatePostHasReactionsOnDeleteQuery, updateAt, postId, postId)
return err
}
func updatePostForReactionsOnInsert(transaction *sqlxTxWrapper, postId string) error {
_, err := transaction.Exec(
`UPDATE
Posts
SET
HasReactions = True,
UpdateAt = ?
WHERE
Id = ?`,
model.GetMillis(),
postId,
)
return err
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestReactionStore(t *testing.T) {
StoreTestWithSqlStore(t, storetest.TestReactionStore)
}

View File

@@ -0,0 +1,165 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/einterfaces"
sq "github.com/mattermost/squirrel"
)
type SqlReadReceiptStore struct {
*SqlStore
metrics einterfaces.MetricsInterface
selectQueryBuilder sq.SelectBuilder
}
func newSqlReadReceiptStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterface) store.ReadReceiptStore {
s := &SqlReadReceiptStore{
SqlStore: sqlStore,
metrics: metrics,
}
s.selectQueryBuilder = s.getQueryBuilder().Select(readReceiptSliceColumns()...).From("ReadReceipts")
return s
}
func readReceiptSliceColumns() []string {
return []string{
"PostId",
"UserId",
"ExpireAt",
}
}
func (s *SqlReadReceiptStore) InvalidateReadReceiptForPostsCache(postID string) {
}
func (s *SqlReadReceiptStore) Save(rctx request.CTX, receipt *model.ReadReceipt) (*model.ReadReceipt, error) {
query := s.getQueryBuilder().
Insert("ReadReceipts").
Columns(readReceiptSliceColumns()...).
Values(
receipt.PostID,
receipt.UserID,
receipt.ExpireAt,
)
_, err := s.GetMaster().ExecBuilder(query)
if err != nil {
return nil, err
}
return receipt, nil
}
func (s *SqlReadReceiptStore) Update(rctx request.CTX, receipt *model.ReadReceipt) (*model.ReadReceipt, error) {
query := s.getQueryBuilder().
Update("ReadReceipts").
Set("ExpireAt", receipt.ExpireAt).
Where(sq.Eq{"PostId": receipt.PostID, "UserId": receipt.UserID})
_, err := s.GetMaster().ExecBuilder(query)
if err != nil {
return nil, err
}
return receipt, nil
}
func (s *SqlReadReceiptStore) Delete(rctx request.CTX, postID, userID string) error {
query := s.getQueryBuilder().
Delete("ReadReceipts").
Where(sq.Eq{"PostId": postID, "UserId": userID})
_, err := s.GetMaster().ExecBuilder(query)
return err
}
func (s *SqlReadReceiptStore) DeleteByPost(rctx request.CTX, postID string) error {
query := s.getQueryBuilder().
Delete("ReadReceipts").
Where(sq.Eq{"PostId": postID})
_, err := s.GetMaster().ExecBuilder(query)
return err
}
func (s *SqlReadReceiptStore) Get(rctx request.CTX, postID, userID string) (*model.ReadReceipt, error) {
query := s.selectQueryBuilder.
Where(sq.Eq{"PostId": postID, "UserId": userID})
var receipt model.ReadReceipt
err := s.GetReplica().GetBuilder(&receipt, query)
if err != nil {
if err == sql.ErrNoRows {
return nil, store.NewErrNotFound("ReadReceipt", postID+"_"+userID)
}
return nil, errors.Wrapf(err, "failed to get ReadReceipt with id=%s", postID+"_"+userID)
}
return &receipt, nil
}
func (s *SqlReadReceiptStore) GetByPost(rctx request.CTX, postID string) ([]*model.ReadReceipt, error) {
query := s.selectQueryBuilder.
Where(sq.Eq{"PostId": postID})
var receipts []*model.ReadReceipt
err := s.GetReplica().SelectBuilder(&receipts, query)
if err != nil {
return nil, errors.Wrapf(err, "failed to get ReadReceipts for postId=%s", postID)
}
return receipts, nil
}
func (s *SqlReadReceiptStore) GetReadCountForPost(rctx request.CTX, postID string) (int64, error) {
query := s.getQueryBuilder().
Select("COUNT(*)").
From("ReadReceipts").
Where(sq.Eq{"PostId": postID})
var count int64
err := s.GetReplica().GetBuilder(&count, query)
if err != nil {
return 0, err
}
return count, nil
}
func (s *SqlReadReceiptStore) GetUnreadCountForPost(rctx request.CTX, post *model.Post) (int64, error) {
// Count channel members who haven't read the post (excluding post author)
// LEFT JOIN with ReadReceipts to find members without a read receipt for this post
unreadQuery := s.getQueryBuilder().
Select("COUNT(*)").
From("ChannelMembers").
LeftJoin("ReadReceipts ON ChannelMembers.UserId = ReadReceipts.UserId AND ReadReceipts.PostId = ?", post.Id).
Where(sq.And{
sq.Eq{"ChannelMembers.ChannelId": post.ChannelId},
sq.NotEq{"ChannelMembers.UserId": post.UserId},
sq.Eq{"ReadReceipts.UserId": nil},
})
var unreadCount int64
// Use master to avoid stale data from replica after writing a read receipt
err := s.GetMaster().GetBuilder(&unreadCount, unreadQuery)
if err != nil {
return -1, errors.Wrapf(err, "failed to get unread count for postId=%s channelId=%s", post.Id, post.ChannelId)
}
// Return true if no one is unread (all have read it)
return unreadCount, nil
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func TestReadReceiptStore(t *testing.T) {
StoreTestWithSqlStore(t, storetest.TestReadReceiptStore)
}

View File

@@ -0,0 +1,453 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"encoding/json"
"fmt"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/store"
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
)
var (
recapColumns = []string{
"Id",
"UserId",
"Title",
"CreateAt",
"UpdateAt",
"DeleteAt",
"ReadAt",
"ViewedAt",
"TotalMessageCount",
"Status",
"BotID",
"ScheduledRecapId",
"SkipReason",
}
recapChannelColumns = []string{
"Id",
"RecapId",
"ChannelId",
"ChannelName",
"Highlights",
"ActionItems",
"SourcePostIds",
"CreateAt",
}
)
type SqlRecapStore struct {
*SqlStore
recapSelectQuery sq.SelectBuilder
recapChannelSelectQuery sq.SelectBuilder
}
func newSqlRecapStore(sqlStore *SqlStore) store.RecapStore {
s := &SqlRecapStore{
SqlStore: sqlStore,
}
s.recapSelectQuery = s.getQueryBuilder().
Select(recapColumns...).
From("Recaps")
s.recapChannelSelectQuery = s.getQueryBuilder().
Select(recapChannelColumns...).
From("RecapChannels")
return s
}
func (s *SqlRecapStore) recapToMap(recap *model.Recap) map[string]any {
return map[string]any{
"Id": recap.Id,
"UserId": recap.UserId,
"Title": recap.Title,
"CreateAt": recap.CreateAt,
"UpdateAt": recap.UpdateAt,
"DeleteAt": recap.DeleteAt,
"ReadAt": recap.ReadAt,
"ViewedAt": recap.ViewedAt,
"TotalMessageCount": recap.TotalMessageCount,
"Status": recap.Status,
"BotID": recap.BotID,
"ScheduledRecapId": recap.ScheduledRecapId,
"SkipReason": recap.SkipReason,
}
}
func (s *SqlRecapStore) recapChannelToMap(rc *model.RecapChannel) (map[string]any, error) {
highlightsJSON, err := json.Marshal(rc.Highlights)
if err != nil {
return nil, errors.Wrap(err, "failed to marshal Highlights")
}
actionItemsJSON, err := json.Marshal(rc.ActionItems)
if err != nil {
return nil, errors.Wrap(err, "failed to marshal ActionItems")
}
sourcePostIdsJSON, err := json.Marshal(rc.SourcePostIds)
if err != nil {
return nil, errors.Wrap(err, "failed to marshal SourcePostIds")
}
return map[string]any{
"Id": rc.Id,
"RecapId": rc.RecapId,
"ChannelId": rc.ChannelId,
"ChannelName": rc.ChannelName,
"Highlights": string(highlightsJSON),
"ActionItems": string(actionItemsJSON),
"SourcePostIds": string(sourcePostIdsJSON),
"CreateAt": rc.CreateAt,
}, nil
}
func (s *SqlRecapStore) SaveRecap(recap *model.Recap) (*model.Recap, error) {
if err := s.saveRecapWithExecutor(s.GetMaster(), recap); err != nil {
return nil, err
}
return recap, nil
}
func (s *SqlRecapStore) SaveRecapIfUnderDailyLimit(recap *model.Recap, since int64, limit int) (*model.Recap, error) {
// SERIALIZABLE prevents the COUNT/INSERT check from racing under READ COMMITTED;
// the retry layer retries the serialization failures this can surface.
tx, err := s.GetMaster().BeginWithIsolation(&sql.TxOptions{Isolation: sql.LevelSerializable})
if err != nil {
return nil, errors.Wrap(err, "failed to begin transaction for SaveRecapIfUnderDailyLimit")
}
defer finalizeTransactionX(tx, &err)
count, err := s.countForUserSinceWithExecutor(tx, recap.UserId, since)
if err != nil {
return nil, err
}
if count >= int64(limit) {
return nil, store.NewErrLimitExceeded("recaps_per_day", int(count), fmt.Sprintf("userId=%s limit=%d", recap.UserId, limit))
}
if err = s.saveRecapWithExecutor(tx, recap); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, errors.Wrap(err, "failed to commit transaction for SaveRecapIfUnderDailyLimit")
}
return recap, nil
}
func (s *SqlRecapStore) saveRecapWithExecutor(executor sqlxExecutor, recap *model.Recap) error {
query := s.getQueryBuilder().
Insert("Recaps").
SetMap(s.recapToMap(recap))
if _, err := executor.ExecBuilder(query); err != nil {
return errors.Wrap(err, "failed to save Recap")
}
return nil
}
func (s *SqlRecapStore) GetRecap(id string) (*model.Recap, error) {
var recap model.Recap
query := s.recapSelectQuery.Where(sq.Eq{"Id": id, "DeleteAt": 0})
if err := s.GetReplica().GetBuilder(&recap, query); err != nil {
if err == sql.ErrNoRows {
return nil, store.NewErrNotFound("Recap", id)
}
return nil, errors.Wrapf(err, "failed to get Recap with id=%s", id)
}
return &recap, nil
}
func (s *SqlRecapStore) GetRecapsForUser(userId string, page, perPage int) ([]*model.Recap, error) {
offset := page * perPage
var recaps []*model.Recap
query := s.recapSelectQuery.
Where(sq.Eq{"UserId": userId, "DeleteAt": 0}).
Where(sq.NotEq{"Status": model.RecapStatusSkipped}). // Skipped recaps are internal audit records, not client-facing.
OrderBy("CreateAt DESC").
Limit(uint64(perPage)).
Offset(uint64(offset))
if err := s.GetReplica().SelectBuilder(&recaps, query); err != nil {
return nil, errors.Wrapf(err, "failed to get Recaps for userId=%s", userId)
}
return recaps, nil
}
func (s *SqlRecapStore) UpdateRecap(recap *model.Recap) (*model.Recap, error) {
query := s.getQueryBuilder().
Update("Recaps").
SetMap(map[string]any{
"Title": recap.Title,
"UpdateAt": recap.UpdateAt,
"TotalMessageCount": recap.TotalMessageCount,
"Status": recap.Status,
"ReadAt": recap.ReadAt,
"ViewedAt": recap.ViewedAt,
}).
Where(sq.Eq{"Id": recap.Id})
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
return nil, errors.Wrapf(err, "failed to update Recap with id=%s", recap.Id)
}
return recap, nil
}
func (s *SqlRecapStore) UpdateRecapStatus(id, status string) error {
updateAt := model.GetMillis()
query := s.getQueryBuilder().
Update("Recaps").
SetMap(map[string]any{
"Status": status,
"UpdateAt": updateAt,
}).
Where(sq.Eq{"Id": id})
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
return errors.Wrapf(err, "failed to update Recap status for id=%s", id)
}
return nil
}
// MarkRecapSkipped flips a still-pending recap to skipped. Scoped to pending so a
// recap a worker has already started processing is never clobbered.
func (s *SqlRecapStore) MarkRecapSkipped(id, reason string) error {
query := s.getQueryBuilder().
Update("Recaps").
SetMap(map[string]any{
"Status": model.RecapStatusSkipped,
"SkipReason": reason,
"UpdateAt": model.GetMillis(),
}).
Where(sq.Eq{"Id": id, "Status": model.RecapStatusPending})
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
return errors.Wrapf(err, "failed to mark Recap as skipped for id=%s", id)
}
return nil
}
func (s *SqlRecapStore) MarkRecapAsRead(id string) error {
now := model.GetMillis()
query := s.getQueryBuilder().
Update("Recaps").
SetMap(map[string]any{
"ReadAt": now,
"UpdateAt": now,
}).
Where(sq.Eq{"Id": id, "ReadAt": 0})
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
return errors.Wrapf(err, "failed to mark Recap as read for id=%s", id)
}
return nil
}
func (s *SqlRecapStore) MarkRecapsAsViewed(userId string, statuses []string) ([]string, error) {
if len(statuses) == 0 {
return nil, nil
}
now := model.GetMillis()
query, args, err := s.getQueryBuilder().
Update("Recaps").
SetMap(map[string]any{
"ViewedAt": now,
"UpdateAt": now,
}).
Where(sq.Eq{"UserId": userId, "ViewedAt": 0, "DeleteAt": 0, "Status": statuses}).
Suffix("RETURNING Id").
ToSql()
if err != nil {
return nil, errors.Wrap(err, "failed to build MarkRecapsAsViewed query")
}
var ids []string
if err := s.GetMaster().Select(&ids, query, args...); err != nil {
return nil, errors.Wrapf(err, "failed to mark recaps as viewed for userId=%s", userId)
}
return ids, nil
}
func (s *SqlRecapStore) DeleteRecap(id string) error {
deleteAt := model.GetMillis()
query := s.getQueryBuilder().
Update("Recaps").
SetMap(map[string]any{
"DeleteAt": deleteAt,
}).
Where(sq.Eq{"Id": id})
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
return errors.Wrapf(err, "failed to delete Recap with id=%s", id)
}
return nil
}
func (s *SqlRecapStore) DeleteRecapChannels(recapId string) error {
query := s.getQueryBuilder().
Delete("RecapChannels").
Where(sq.Eq{"RecapId": recapId})
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
return errors.Wrapf(err, "failed to delete RecapChannels for recapId=%s", recapId)
}
return nil
}
func (s *SqlRecapStore) SaveRecapChannel(recapChannel *model.RecapChannel) error {
rcMap, err := s.recapChannelToMap(recapChannel)
if err != nil {
return err
}
query := s.getQueryBuilder().
Insert("RecapChannels").
SetMap(rcMap)
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
return errors.Wrap(err, "failed to save RecapChannel")
}
return nil
}
func (s *SqlRecapStore) GetRecapChannelsByRecapId(recapId string) ([]*model.RecapChannel, error) {
query := s.recapChannelSelectQuery.
Where(sq.Eq{"RecapId": recapId}).
OrderBy("CreateAt ASC")
var dbRecapChannels []struct {
Id string
RecapId string
ChannelId string
ChannelName string
Highlights string
ActionItems string
SourcePostIds string
CreateAt int64
}
if err := s.GetReplica().SelectBuilder(&dbRecapChannels, query); err != nil {
return nil, errors.Wrapf(err, "failed to get RecapChannels for recapId=%s", recapId)
}
recapChannels := make([]*model.RecapChannel, 0, len(dbRecapChannels))
for _, dbRC := range dbRecapChannels {
rc := &model.RecapChannel{
Id: dbRC.Id,
RecapId: dbRC.RecapId,
ChannelId: dbRC.ChannelId,
ChannelName: dbRC.ChannelName,
CreateAt: dbRC.CreateAt,
}
// Unmarshal JSON strings back to arrays
if err := json.Unmarshal([]byte(dbRC.Highlights), &rc.Highlights); err != nil {
return nil, errors.Wrap(err, fmt.Sprintf("failed to unmarshal Highlights for recapChannel id=%s", dbRC.Id))
}
if err := json.Unmarshal([]byte(dbRC.ActionItems), &rc.ActionItems); err != nil {
return nil, errors.Wrap(err, fmt.Sprintf("failed to unmarshal ActionItems for recapChannel id=%s", dbRC.Id))
}
if err := json.Unmarshal([]byte(dbRC.SourcePostIds), &rc.SourcePostIds); err != nil {
return nil, errors.Wrap(err, fmt.Sprintf("failed to unmarshal SourcePostIds for recapChannel id=%s", dbRC.Id))
}
recapChannels = append(recapChannels, rc)
}
return recapChannels, nil
}
// CountForUserSince returns count of recaps created by user since given timestamp.
// Excludes skipped recaps from the count, but still counts soft-deleted recaps
// because they already consumed AI usage.
func (s *SqlRecapStore) CountForUserSince(userId string, since int64) (int64, error) {
return s.countForUserSinceWithExecutor(s.GetReplica(), userId, since)
}
func (s *SqlRecapStore) countForUserSinceWithExecutor(executor sqlxExecutor, userId string, since int64) (int64, error) {
query := s.getQueryBuilder().
Select("COUNT(*)").
From("Recaps").
Where(sq.Eq{"UserId": userId}).
Where(sq.GtOrEq{"CreateAt": since}).
Where(sq.NotEq{"Status": model.RecapStatusSkipped}) // Don't count skipped recaps
var count int64
err := executor.GetBuilder(&count, query)
if err != nil {
return 0, errors.Wrap(err, "failed to count recaps for user since timestamp")
}
return count, nil
}
func (s *SqlRecapStore) SumTotalMessageCountForUserSince(userId string, since int64) (int64, error) {
query := s.getQueryBuilder().
Select("COALESCE(SUM(TotalMessageCount), 0)").
From("Recaps").
Where(sq.Eq{"UserId": userId}).
Where(sq.GtOrEq{"CreateAt": since}).
Where(sq.NotEq{"Status": model.RecapStatusSkipped})
var total int64
err := s.GetReplica().GetBuilder(&total, query)
if err != nil {
return 0, errors.Wrap(err, "failed to sum recap message count for user since timestamp")
}
return total, nil
}
// GetLastCompletedManualRecap returns the most recent completed manual recap for user.
// Manual recap = ScheduledRecapId is empty. Used for cooldown checking, including
// soft-deleted recaps because deleting a recap should not bypass cooldown.
// Returns nil, nil if no manual recap exists.
func (s *SqlRecapStore) GetLastCompletedManualRecap(userId string) (*model.Recap, error) {
var recap model.Recap
query := s.recapSelectQuery.
Where(sq.Eq{"UserId": userId}).
Where(sq.Eq{"Status": model.RecapStatusCompleted}).
Where(sq.Or{sq.Eq{"ScheduledRecapId": ""}, sq.Expr("ScheduledRecapId IS NULL")}). // Manual = no scheduled recap ID (NULL for pre-migration rows)
OrderBy("CreateAt DESC").
Limit(1)
err := s.GetReplica().GetBuilder(&recap, query)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil // No manual recap found - not an error
}
return nil, errors.Wrap(err, "failed to get last completed manual recap")
}
return &recap, nil
}

View File

@@ -0,0 +1,518 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRecapStore(t *testing.T) {
StoreTest(t, func(t *testing.T, rctx request.CTX, ss store.Store) {
t.Run("SaveAndGetRecap", func(t *testing.T) {
recap := &model.Recap{
Id: model.NewId(),
UserId: model.NewId(),
Title: "Test Recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 10,
Status: model.RecapStatusPending,
BotID: "test-bot-id",
}
savedRecap, err := ss.Recap().SaveRecap(recap)
require.NoError(t, err)
assert.Equal(t, recap.Id, savedRecap.Id)
assert.Equal(t, recap.UserId, savedRecap.UserId)
assert.Equal(t, recap.Title, savedRecap.Title)
assert.Equal(t, recap.BotID, savedRecap.BotID)
retrievedRecap, err := ss.Recap().GetRecap(recap.Id)
require.NoError(t, err)
assert.Equal(t, recap.Id, retrievedRecap.Id)
assert.Equal(t, recap.UserId, retrievedRecap.UserId)
assert.Equal(t, recap.Title, retrievedRecap.Title)
assert.Equal(t, recap.TotalMessageCount, retrievedRecap.TotalMessageCount)
assert.Equal(t, recap.Status, retrievedRecap.Status)
assert.Equal(t, recap.BotID, retrievedRecap.BotID)
})
t.Run("GetRecapsForUser", func(t *testing.T) {
userId := model.NewId()
// Create multiple recaps for the same user
for range 3 {
recap := &model.Recap{
Id: model.NewId(),
UserId: userId,
Title: "Test Recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 10,
Status: model.RecapStatusCompleted,
BotID: "test-bot-id",
}
_, err := ss.Recap().SaveRecap(recap)
require.NoError(t, err)
}
recaps, err := ss.Recap().GetRecapsForUser(userId, 0, 10)
require.NoError(t, err)
assert.Len(t, recaps, 3)
})
t.Run("GetRecapsForUserExcludesSkipped", func(t *testing.T) {
userId := model.NewId()
save := func(status string) string {
r := &model.Recap{
Id: model.NewId(),
UserId: userId,
Title: "Test Recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
TotalMessageCount: 1,
Status: status,
BotID: "test-bot-id",
}
_, err := ss.Recap().SaveRecap(r)
require.NoError(t, err)
return r.Id
}
completed := save(model.RecapStatusCompleted)
failed := save(model.RecapStatusFailed)
skipped := save(model.RecapStatusSkipped)
recaps, err := ss.Recap().GetRecapsForUser(userId, 0, 10)
require.NoError(t, err)
ids := make([]string, 0, len(recaps))
for _, r := range recaps {
ids = append(ids, r.Id)
}
assert.ElementsMatch(t, []string{completed, failed}, ids)
assert.NotContains(t, ids, skipped)
})
t.Run("UpdateRecapStatus", func(t *testing.T) {
recap := &model.Recap{
Id: model.NewId(),
UserId: model.NewId(),
Title: "Test Recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 10,
Status: model.RecapStatusPending,
BotID: "test-bot-id",
}
_, err := ss.Recap().SaveRecap(recap)
require.NoError(t, err)
err = ss.Recap().UpdateRecapStatus(recap.Id, model.RecapStatusCompleted)
require.NoError(t, err)
updatedRecap, err := ss.Recap().GetRecap(recap.Id)
require.NoError(t, err)
assert.Equal(t, model.RecapStatusCompleted, updatedRecap.Status)
})
t.Run("SaveAndGetRecapChannels", func(t *testing.T) {
recapId := model.NewId()
// Create a recap first
recap := &model.Recap{
Id: recapId,
UserId: model.NewId(),
Title: "Test Recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 10,
Status: model.RecapStatusPending,
BotID: "test-bot-id",
}
_, err := ss.Recap().SaveRecap(recap)
require.NoError(t, err)
// Create recap channels
recapChannel1 := &model.RecapChannel{
Id: model.NewId(),
RecapId: recapId,
ChannelId: model.NewId(),
ChannelName: "Test Channel 1",
Highlights: []string{"Highlight 1", "Highlight 2"},
ActionItems: []string{"Action 1"},
SourcePostIds: []string{model.NewId(), model.NewId()},
CreateAt: model.GetMillis(),
}
recapChannel2 := &model.RecapChannel{
Id: model.NewId(),
RecapId: recapId,
ChannelId: model.NewId(),
ChannelName: "Test Channel 2",
Highlights: []string{},
ActionItems: []string{"Action 2", "Action 3"},
SourcePostIds: []string{model.NewId()},
CreateAt: model.GetMillis(),
}
err = ss.Recap().SaveRecapChannel(recapChannel1)
require.NoError(t, err)
err = ss.Recap().SaveRecapChannel(recapChannel2)
require.NoError(t, err)
// Retrieve recap channels
channels, err := ss.Recap().GetRecapChannelsByRecapId(recapId)
require.NoError(t, err)
assert.Len(t, channels, 2)
// Verify data integrity
for _, ch := range channels {
if ch.Id == recapChannel1.Id {
assert.Equal(t, recapChannel1.ChannelName, ch.ChannelName)
assert.Equal(t, recapChannel1.Highlights, ch.Highlights)
assert.Equal(t, recapChannel1.ActionItems, ch.ActionItems)
assert.Equal(t, recapChannel1.SourcePostIds, ch.SourcePostIds)
} else if ch.Id == recapChannel2.Id {
assert.Equal(t, recapChannel2.ChannelName, ch.ChannelName)
assert.Equal(t, recapChannel2.Highlights, ch.Highlights)
assert.Equal(t, recapChannel2.ActionItems, ch.ActionItems)
assert.Equal(t, recapChannel2.SourcePostIds, ch.SourcePostIds)
}
}
})
t.Run("CountForUserSinceIncludesSoftDeletedRecaps", func(t *testing.T) {
userId := model.NewId()
since := model.GetMillis() - 1000
completedRecap := &model.Recap{
Id: model.NewId(),
UserId: userId,
Title: "Completed Recap",
CreateAt: since + 1,
UpdateAt: since + 1,
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 10,
Status: model.RecapStatusCompleted,
BotID: "test-bot-id",
}
_, err := ss.Recap().SaveRecap(completedRecap)
require.NoError(t, err)
skippedRecap := &model.Recap{
Id: model.NewId(),
UserId: userId,
Title: "Skipped Recap",
CreateAt: since + 2,
UpdateAt: since + 2,
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 0,
Status: model.RecapStatusSkipped,
BotID: "test-bot-id",
}
_, err = ss.Recap().SaveRecap(skippedRecap)
require.NoError(t, err)
err = ss.Recap().DeleteRecap(completedRecap.Id)
require.NoError(t, err)
err = ss.Recap().DeleteRecap(skippedRecap.Id)
require.NoError(t, err)
count, err := ss.Recap().CountForUserSince(userId, since)
require.NoError(t, err)
assert.Equal(t, int64(1), count)
totalMessages, err := ss.Recap().SumTotalMessageCountForUserSince(userId, since)
require.NoError(t, err)
assert.Equal(t, int64(10), totalMessages)
})
t.Run("SaveRecapIfUnderDailyLimit", func(t *testing.T) {
since := model.GetMillis() - 1000
user, err := ss.User().Save(rctx, &model.User{
Username: model.NewUsername(),
Email: model.NewId() + "@example.com",
})
require.NoError(t, err)
userId := user.Id
recap := &model.Recap{
Id: model.NewId(),
UserId: userId,
Title: "First Recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 10,
Status: model.RecapStatusPending,
BotID: "test-bot-id",
}
_, err = ss.Recap().SaveRecapIfUnderDailyLimit(recap, since, 1)
require.NoError(t, err)
overLimitRecap := &model.Recap{
Id: model.NewId(),
UserId: userId,
Title: "Second Recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 10,
Status: model.RecapStatusPending,
BotID: "test-bot-id",
}
_, err = ss.Recap().SaveRecapIfUnderDailyLimit(overLimitRecap, since, 1)
require.Error(t, err)
var limitErr *store.ErrLimitExceeded
require.ErrorAs(t, err, &limitErr)
})
t.Run("MarkRecapSkippedFreesDailyCount", func(t *testing.T) {
userId := model.NewId()
since := model.GetMillis() - 1000
recap := &model.Recap{
Id: model.NewId(),
UserId: userId,
Title: "Orphan Recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
Status: model.RecapStatusPending,
BotID: "test-bot-id",
}
_, err := ss.Recap().SaveRecap(recap)
require.NoError(t, err)
count, err := ss.Recap().CountForUserSince(userId, since)
require.NoError(t, err)
assert.Equal(t, int64(1), count, "pending recap should count toward the daily limit")
err = ss.Recap().MarkRecapSkipped(recap.Id, model.SkipReasonJobCreationFailed)
require.NoError(t, err)
updated, err := ss.Recap().GetRecap(recap.Id)
require.NoError(t, err)
assert.Equal(t, model.RecapStatusSkipped, updated.Status)
assert.Equal(t, model.SkipReasonJobCreationFailed, updated.SkipReason)
count, err = ss.Recap().CountForUserSince(userId, since)
require.NoError(t, err)
assert.Equal(t, int64(0), count, "skipped recap should no longer consume a daily slot")
// The pending-only guard must not clobber a recap a worker has already started.
completed := &model.Recap{
Id: model.NewId(),
UserId: userId,
Title: "Completed Recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
Status: model.RecapStatusCompleted,
BotID: "test-bot-id",
}
_, err = ss.Recap().SaveRecap(completed)
require.NoError(t, err)
err = ss.Recap().MarkRecapSkipped(completed.Id, model.SkipReasonJobCreationFailed)
require.NoError(t, err)
unchanged, err := ss.Recap().GetRecap(completed.Id)
require.NoError(t, err)
assert.Equal(t, model.RecapStatusCompleted, unchanged.Status, "non-pending recap must not be flipped to skipped")
})
t.Run("GetLastCompletedManualRecapIncludesSoftDeletedRecaps", func(t *testing.T) {
userId := model.NewId()
baseTime := model.GetMillis()
olderRecap := &model.Recap{
Id: model.NewId(),
UserId: userId,
Title: "Older Recap",
CreateAt: baseTime - 60000,
UpdateAt: baseTime - 60000,
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 10,
Status: model.RecapStatusCompleted,
BotID: "test-bot-id",
}
_, err := ss.Recap().SaveRecap(olderRecap)
require.NoError(t, err)
newerRecap := &model.Recap{
Id: model.NewId(),
UserId: userId,
Title: "Newer Recap",
CreateAt: baseTime - 30000,
UpdateAt: baseTime - 30000,
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 10,
Status: model.RecapStatusCompleted,
BotID: "test-bot-id",
}
_, err = ss.Recap().SaveRecap(newerRecap)
require.NoError(t, err)
err = ss.Recap().DeleteRecap(newerRecap.Id)
require.NoError(t, err)
lastRecap, err := ss.Recap().GetLastCompletedManualRecap(userId)
require.NoError(t, err)
require.NotNil(t, lastRecap)
assert.Equal(t, newerRecap.Id, lastRecap.Id)
})
t.Run("MarkRecapsAsViewed", func(t *testing.T) {
userId := model.NewId()
otherUserId := model.NewId()
save := func(userID, status string, viewedAt int64) string {
r := &model.Recap{
Id: model.NewId(),
UserId: userID,
Title: "T",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
Status: status,
ViewedAt: viewedAt,
BotID: "bot",
TotalMessageCount: 1,
}
_, err := ss.Recap().SaveRecap(r)
require.NoError(t, err)
return r.Id
}
completed := save(userId, model.RecapStatusCompleted, 0)
failed := save(userId, model.RecapStatusFailed, 0)
pending := save(userId, model.RecapStatusPending, 0)
processing := save(userId, model.RecapStatusProcessing, 0)
alreadyViewed := save(userId, model.RecapStatusCompleted, 1234)
otherUser := save(otherUserId, model.RecapStatusCompleted, 0)
ids, err := ss.Recap().MarkRecapsAsViewed(userId, []string{model.RecapStatusCompleted, model.RecapStatusFailed})
require.NoError(t, err)
assert.ElementsMatch(t, []string{completed, failed}, ids)
// completed and failed are now viewed
r1, err := ss.Recap().GetRecap(completed)
require.NoError(t, err)
assert.NotZero(t, r1.ViewedAt)
r2, err := ss.Recap().GetRecap(failed)
require.NoError(t, err)
assert.NotZero(t, r2.ViewedAt)
// pending/processing untouched
r3, err := ss.Recap().GetRecap(pending)
require.NoError(t, err)
assert.Zero(t, r3.ViewedAt)
r4, err := ss.Recap().GetRecap(processing)
require.NoError(t, err)
assert.Zero(t, r4.ViewedAt)
// already-viewed unchanged
r5, err := ss.Recap().GetRecap(alreadyViewed)
require.NoError(t, err)
assert.Equal(t, int64(1234), r5.ViewedAt)
// other user untouched
r6, err := ss.Recap().GetRecap(otherUser)
require.NoError(t, err)
assert.Zero(t, r6.ViewedAt)
// idempotent: second call returns no ids
ids2, err := ss.Recap().MarkRecapsAsViewed(userId, []string{model.RecapStatusCompleted, model.RecapStatusFailed})
require.NoError(t, err)
assert.Empty(t, ids2)
})
t.Run("UpdateRecap persists ReadAt and ViewedAt resets", func(t *testing.T) {
recap := &model.Recap{
Id: model.NewId(),
UserId: model.NewId(),
Title: "T",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
ReadAt: 500,
ViewedAt: 600,
Status: model.RecapStatusCompleted,
TotalMessageCount: 1,
BotID: "bot",
}
_, err := ss.Recap().SaveRecap(recap)
require.NoError(t, err)
// RegenerateRecap-style reset: clear both timestamps and revert status.
recap.ReadAt = 0
recap.ViewedAt = 0
recap.Status = model.RecapStatusPending
recap.UpdateAt = model.GetMillis()
_, err = ss.Recap().UpdateRecap(recap)
require.NoError(t, err)
fresh, err := ss.Recap().GetRecap(recap.Id)
require.NoError(t, err)
assert.Zero(t, fresh.ReadAt, "ReadAt should be reset by UpdateRecap")
assert.Zero(t, fresh.ViewedAt, "ViewedAt should be reset by UpdateRecap")
assert.Equal(t, model.RecapStatusPending, fresh.Status)
})
t.Run("DeleteRecap", func(t *testing.T) {
recap := &model.Recap{
Id: model.NewId(),
UserId: model.NewId(),
Title: "Test Recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 10,
Status: model.RecapStatusCompleted,
BotID: "test-bot-id",
}
_, err := ss.Recap().SaveRecap(recap)
require.NoError(t, err)
err = ss.Recap().DeleteRecap(recap.Id)
require.NoError(t, err)
// Verify soft delete - should not appear in user's recaps
recaps, err := ss.Recap().GetRecapsForUser(recap.UserId, 0, 10)
require.NoError(t, err)
assert.Len(t, recaps, 0)
_, err = ss.Recap().GetRecap(recap.Id)
require.Error(t, err)
var nfErr *store.ErrNotFound
require.ErrorAs(t, err, &nfErr)
})
})
}

Some files were not shown because too many files have changed in this diff Show More