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
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:
980
server/channels/store/sqlstore/access_control_policy_store.go
Normal file
980
server/channels/store/sqlstore/access_control_policy_store.go
Normal 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() {}
|
||||
@@ -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)
|
||||
}
|
||||
51
server/channels/store/sqlstore/adapters.go
Normal file
51
server/channels/store/sqlstore/adapters.go
Normal 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
|
||||
}
|
||||
21
server/channels/store/sqlstore/adapters_test.go
Normal file
21
server/channels/store/sqlstore/adapters_test.go
Normal 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)
|
||||
}
|
||||
380
server/channels/store/sqlstore/attributes_store.go
Normal file
380
server/channels/store/sqlstore/attributes_store.go
Normal 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
|
||||
}
|
||||
14
server/channels/store/sqlstore/attributes_store_test.go
Normal file
14
server/channels/store/sqlstore/attributes_store_test.go
Normal 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)
|
||||
}
|
||||
84
server/channels/store/sqlstore/audit_store.go
Normal file
84
server/channels/store/sqlstore/audit_store.go
Normal 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
|
||||
}
|
||||
14
server/channels/store/sqlstore/audit_store_test.go
Normal file
14
server/channels/store/sqlstore/audit_store_test.go
Normal 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)
|
||||
}
|
||||
497
server/channels/store/sqlstore/autotranslation_store.go
Normal file
497
server/channels/store/sqlstore/autotranslation_store.go
Normal 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
|
||||
}
|
||||
14
server/channels/store/sqlstore/autotranslation_store_test.go
Normal file
14
server/channels/store/sqlstore/autotranslation_store_test.go
Normal 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)
|
||||
}
|
||||
270
server/channels/store/sqlstore/bot_store.go
Normal file
270
server/channels/store/sqlstore/bot_store.go
Normal 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
|
||||
}
|
||||
14
server/channels/store/sqlstore/bot_store_test.go
Normal file
14
server/channels/store/sqlstore/bot_store_test.go
Normal 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)
|
||||
}
|
||||
391
server/channels/store/sqlstore/bulk_insert_test.go
Normal file
391
server/channels/store/sqlstore/bulk_insert_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
394
server/channels/store/sqlstore/channel_bookmark_store.go
Normal file
394
server/channels/store/sqlstore/channel_bookmark_store.go
Normal 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(¤tBookmarksCount, 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
83
server/channels/store/sqlstore/channel_guard_store.go
Normal file
83
server/channels/store/sqlstore/channel_guard_store.go
Normal 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
|
||||
}
|
||||
14
server/channels/store/sqlstore/channel_guard_store_test.go
Normal file
14
server/channels/store/sqlstore/channel_guard_store_test.go
Normal 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)
|
||||
}
|
||||
244
server/channels/store/sqlstore/channel_join_request_store.go
Normal file
244
server/channels/store/sqlstore/channel_join_request_store.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
380
server/channels/store/sqlstore/channel_member_history_store.go
Normal file
380
server/channels/store/sqlstore/channel_member_history_store.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
4621
server/channels/store/sqlstore/channel_store.go
Normal file
4621
server/channels/store/sqlstore/channel_store.go
Normal file
File diff suppressed because it is too large
Load Diff
1071
server/channels/store/sqlstore/channel_store_categories.go
Normal file
1071
server/channels/store/sqlstore/channel_store_categories.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
1402
server/channels/store/sqlstore/channel_store_test.go
Normal file
1402
server/channels/store/sqlstore/channel_store_test.go
Normal file
File diff suppressed because it is too large
Load Diff
156
server/channels/store/sqlstore/cluster_discovery_store.go
Normal file
156
server/channels/store/sqlstore/cluster_discovery_store.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
261
server/channels/store/sqlstore/command_store.go
Normal file
261
server/channels/store/sqlstore/command_store.go
Normal 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
|
||||
}
|
||||
14
server/channels/store/sqlstore/command_store_test.go
Normal file
14
server/channels/store/sqlstore/command_store_test.go
Normal 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)
|
||||
}
|
||||
137
server/channels/store/sqlstore/command_webhook_store.go
Normal file
137
server/channels/store/sqlstore/command_webhook_store.go
Normal 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.")
|
||||
}
|
||||
}
|
||||
14
server/channels/store/sqlstore/command_webhook_store_test.go
Normal file
14
server/channels/store/sqlstore/command_webhook_store_test.go
Normal 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)
|
||||
}
|
||||
372
server/channels/store/sqlstore/compliance_store.go
Normal file
372
server/channels/store/sqlstore/compliance_store.go
Normal 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
|
||||
}
|
||||
14
server/channels/store/sqlstore/compliance_store_test.go
Normal file
14
server/channels/store/sqlstore/compliance_store_test.go
Normal 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)
|
||||
}
|
||||
226
server/channels/store/sqlstore/content_flagging_store.go
Normal file
226
server/channels/store/sqlstore/content_flagging_store.go
Normal 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() {}
|
||||
@@ -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)
|
||||
}
|
||||
29
server/channels/store/sqlstore/context.go
Normal file
29
server/channels/store/sqlstore/context.go
Normal 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()
|
||||
}
|
||||
51
server/channels/store/sqlstore/context_test.go
Normal file
51
server/channels/store/sqlstore/context_test.go
Normal 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()))
|
||||
})
|
||||
}
|
||||
110
server/channels/store/sqlstore/delivery_tracking_store.go
Normal file
110
server/channels/store/sqlstore/delivery_tracking_store.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
123
server/channels/store/sqlstore/desktop_tokens_store.go
Normal file
123
server/channels/store/sqlstore/desktop_tokens_store.go
Normal 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
|
||||
}
|
||||
14
server/channels/store/sqlstore/desktop_tokens_store_test.go
Normal file
14
server/channels/store/sqlstore/desktop_tokens_store_test.go
Normal 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)
|
||||
}
|
||||
178
server/channels/store/sqlstore/diagnostics.go
Normal file
178
server/channels/store/sqlstore/diagnostics.go
Normal 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
|
||||
}
|
||||
83
server/channels/store/sqlstore/diagnostics_test.go
Normal file
83
server/channels/store/sqlstore/diagnostics_test.go
Normal 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)
|
||||
}
|
||||
353
server/channels/store/sqlstore/draft_store.go
Normal file
353
server/channels/store/sqlstore/draft_store.go
Normal 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
|
||||
}
|
||||
14
server/channels/store/sqlstore/draft_store_test.go
Normal file
14
server/channels/store/sqlstore/draft_store_test.go
Normal 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)
|
||||
}
|
||||
145
server/channels/store/sqlstore/emoji_store.go
Normal file
145
server/channels/store/sqlstore/emoji_store.go
Normal 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
|
||||
}
|
||||
14
server/channels/store/sqlstore/emoji_store_test.go
Normal file
14
server/channels/store/sqlstore/emoji_store_test.go
Normal 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)
|
||||
}
|
||||
823
server/channels/store/sqlstore/file_info_store.go
Normal file
823
server/channels/store/sqlstore/file_info_store.go
Normal 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
|
||||
}
|
||||
19
server/channels/store/sqlstore/file_info_store_test.go
Normal file
19
server/channels/store/sqlstore/file_info_store_test.go
Normal 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)
|
||||
}
|
||||
2035
server/channels/store/sqlstore/group_store.go
Normal file
2035
server/channels/store/sqlstore/group_store.go
Normal file
File diff suppressed because it is too large
Load Diff
14
server/channels/store/sqlstore/group_store_test.go
Normal file
14
server/channels/store/sqlstore/group_store_test.go
Normal 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)
|
||||
}
|
||||
19
server/channels/store/sqlstore/init_test.go
Normal file
19
server/channels/store/sqlstore/init_test.go
Normal 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()
|
||||
}
|
||||
546
server/channels/store/sqlstore/integrity.go
Normal file
546
server/channels/store/sqlstore/integrity.go
Normal 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)
|
||||
}
|
||||
1720
server/channels/store/sqlstore/integrity_test.go
Normal file
1720
server/channels/store/sqlstore/integrity_test.go
Normal file
File diff suppressed because it is too large
Load Diff
549
server/channels/store/sqlstore/job_store.go
Normal file
549
server/channels/store/sqlstore/job_store.go
Normal 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
|
||||
}
|
||||
14
server/channels/store/sqlstore/job_store_test.go
Normal file
14
server/channels/store/sqlstore/job_store_test.go
Normal 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)
|
||||
}
|
||||
97
server/channels/store/sqlstore/license_store.go
Normal file
97
server/channels/store/sqlstore/license_store.go
Normal 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
|
||||
}
|
||||
14
server/channels/store/sqlstore/license_store_test.go
Normal file
14
server/channels/store/sqlstore/license_store_test.go
Normal 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)
|
||||
}
|
||||
99
server/channels/store/sqlstore/link_metadata_store.go
Normal file
99
server/channels/store/sqlstore/link_metadata_store.go
Normal 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
|
||||
}
|
||||
14
server/channels/store/sqlstore/link_metadata_store_test.go
Normal file
14
server/channels/store/sqlstore/link_metadata_store_test.go
Normal 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)
|
||||
}
|
||||
44
server/channels/store/sqlstore/main_test.go
Normal file
44
server/channels/store/sqlstore/main_test.go
Normal 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()
|
||||
}
|
||||
221
server/channels/store/sqlstore/migrate.go
Normal file
221
server/channels/store/sqlstore/migrate.go
Normal 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)
|
||||
}
|
||||
152
server/channels/store/sqlstore/migrate_test.go
Normal file
152
server/channels/store/sqlstore/migrate_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
336
server/channels/store/sqlstore/migration_000185_test.go
Normal file
336
server/channels/store/sqlstore/migration_000185_test.go
Normal 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")
|
||||
}
|
||||
56
server/channels/store/sqlstore/migration_000194_test.go
Normal file
56
server/channels/store/sqlstore/migration_000194_test.go
Normal 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")
|
||||
}
|
||||
171
server/channels/store/sqlstore/migration_000198_test.go
Normal file
171
server/channels/store/sqlstore/migration_000198_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
116
server/channels/store/sqlstore/migration_000216_test.go
Normal file
116
server/channels/store/sqlstore/migration_000216_test.go
Normal 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")
|
||||
}
|
||||
121
server/channels/store/sqlstore/migration_000217_test.go
Normal file
121
server/channels/store/sqlstore/migration_000217_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
109
server/channels/store/sqlstore/notify_admin_store.go
Normal file
109
server/channels/store/sqlstore/notify_admin_store.go
Normal 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
|
||||
}
|
||||
14
server/channels/store/sqlstore/notify_admin_store_test.go
Normal file
14
server/channels/store/sqlstore/notify_admin_store_test.go
Normal 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)
|
||||
}
|
||||
372
server/channels/store/sqlstore/oauth_store.go
Normal file
372
server/channels/store/sqlstore/oauth_store.go
Normal 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
|
||||
}
|
||||
14
server/channels/store/sqlstore/oauth_store_test.go
Normal file
14
server/channels/store/sqlstore/oauth_store_test.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
316
server/channels/store/sqlstore/plugin_store.go
Normal file
316
server/channels/store/sqlstore/plugin_store.go
Normal 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
|
||||
}
|
||||
14
server/channels/store/sqlstore/plugin_store_test.go
Normal file
14
server/channels/store/sqlstore/plugin_store_test.go
Normal 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)
|
||||
}
|
||||
372
server/channels/store/sqlstore/post_acknowledgements_store.go
Normal file
372
server/channels/store/sqlstore/post_acknowledgements_store.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
195
server/channels/store/sqlstore/post_priority_store.go
Normal file
195
server/channels/store/sqlstore/post_priority_store.go
Normal 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
|
||||
}
|
||||
14
server/channels/store/sqlstore/post_priority_store_test.go
Normal file
14
server/channels/store/sqlstore/post_priority_store_test.go
Normal 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)
|
||||
}
|
||||
3548
server/channels/store/sqlstore/post_store.go
Normal file
3548
server/channels/store/sqlstore/post_store.go
Normal file
File diff suppressed because it is too large
Load Diff
19
server/channels/store/sqlstore/post_store_test.go
Normal file
19
server/channels/store/sqlstore/post_store_test.go
Normal 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)
|
||||
}
|
||||
316
server/channels/store/sqlstore/preference_store.go
Normal file
316
server/channels/store/sqlstore/preference_store.go
Normal 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
|
||||
}
|
||||
84
server/channels/store/sqlstore/preference_store_test.go
Normal file
84
server/channels/store/sqlstore/preference_store_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
129
server/channels/store/sqlstore/product_notices_store.go
Normal file
129
server/channels/store/sqlstore/product_notices_store.go
Normal 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(¬iceStates, 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`, ¬iceStates[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(¬iceStates, query); err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to get ProductNoticeViewState with userId=%s", userId)
|
||||
}
|
||||
return noticeStates, nil
|
||||
}
|
||||
14
server/channels/store/sqlstore/product_notices_store_test.go
Normal file
14
server/channels/store/sqlstore/product_notices_store_test.go
Normal 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)
|
||||
}
|
||||
746
server/channels/store/sqlstore/property_field_store.go
Normal file
746
server/channels/store/sqlstore/property_field_store.go
Normal 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
|
||||
}
|
||||
14
server/channels/store/sqlstore/property_field_store_test.go
Normal file
14
server/channels/store/sqlstore/property_field_store_test.go
Normal 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)
|
||||
}
|
||||
109
server/channels/store/sqlstore/property_group_store.go
Normal file
109
server/channels/store/sqlstore/property_group_store.go
Normal 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
|
||||
}
|
||||
14
server/channels/store/sqlstore/property_group_store_test.go
Normal file
14
server/channels/store/sqlstore/property_group_store_test.go
Normal 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)
|
||||
}
|
||||
407
server/channels/store/sqlstore/property_value_store.go
Normal file
407
server/channels/store/sqlstore/property_value_store.go
Normal 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
|
||||
}
|
||||
14
server/channels/store/sqlstore/property_value_store_test.go
Normal file
14
server/channels/store/sqlstore/property_value_store_test.go
Normal 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)
|
||||
}
|
||||
400
server/channels/store/sqlstore/reaction_store.go
Normal file
400
server/channels/store/sqlstore/reaction_store.go
Normal 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
|
||||
}
|
||||
14
server/channels/store/sqlstore/reaction_store_test.go
Normal file
14
server/channels/store/sqlstore/reaction_store_test.go
Normal 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)
|
||||
}
|
||||
165
server/channels/store/sqlstore/read_receipt_store.go
Normal file
165
server/channels/store/sqlstore/read_receipt_store.go
Normal 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
|
||||
}
|
||||
14
server/channels/store/sqlstore/read_receipt_store_test.go
Normal file
14
server/channels/store/sqlstore/read_receipt_store_test.go
Normal 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)
|
||||
}
|
||||
453
server/channels/store/sqlstore/recap_store.go
Normal file
453
server/channels/store/sqlstore/recap_store.go
Normal 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
|
||||
}
|
||||
518
server/channels/store/sqlstore/recap_store_test.go
Normal file
518
server/channels/store/sqlstore/recap_store_test.go
Normal 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
Reference in New Issue
Block a user