From ecdce71fc449602c76f19baf895dbeee733ab629 Mon Sep 17 00:00:00 2001 From: Miguel de la Cruz Date: Mon, 13 Jan 2025 12:41:44 +0100 Subject: [PATCH] Adds the main Property System Architecture components (#29644) * Adds the main Property System Architecture components This change adds the necessary migrations for the Property Groups, Fields and Values tables to be created, the store layer and a Property Service that can be used from the app layer. * Update property field type to use user instead of person * Update PropertyFields to allow for unique nondeleted fields and remove redundant indexes * Update PropertyValues to allow for unique nondeleted fields and remove redundant indexes * Use StringMap instead of the map[string]any on property fields * Add i18n strings * Revert "Use StringMap instead of the map[string]any on property fields" This reverts commit e2735ab0f8589d2524d636419ca0cb144575c4d6. * Cast JSON binary data to string and add todo note for StringMap use * Add mocks to the retrylayer tests * Cast JSON binary data to string in property value store * Check for binary parameter instead of casting to string for JSON data * Check property field type is one of the allowed ones * Avoid reusing err variable to be explicit about the returned value * Merge Property System Migrations into one file * Adds NOT NULL to timestamps at the DB level * Update stores to use tableSelectQuery instead of a slice var * Update PropertyField model translations to be more explicit and avoid repetition * Update PropertyValue model translations to be more explicit and avoid repetition * Use ExecBuilder instead of ToSql&Exec * Update property field errors to add context * Ensure PerPage is greater than zero * Update store errors to give more context * Use ExecBuilder in the property stores where possible * Add an on conflict suffix to the group register to avoid race conditions * Remove badly used translation string * Remove unused get in register group method --------- Co-authored-by: Mattermost Build --- .../channels/app/properties/property_field.go | 44 ++ .../channels/app/properties/property_group.go | 16 + .../channels/app/properties/property_value.go | 41 ++ server/channels/app/properties/service.go | 41 ++ server/channels/app/server.go | 11 + server/channels/db/migrations/migrations.list | 4 + ..._add_property_system_architecture.down.sql | 3 + ...29_add_property_system_architecture.up.sql | 47 ++ ..._add_property_system_architecture.down.sql | 3 + ...29_add_property_system_architecture.up.sql | 55 ++ .../opentracinglayer/opentracinglayer.go | 303 ++++++++++ .../channels/store/retrylayer/retrylayer.go | 348 ++++++++++++ .../store/retrylayer/retrylayer_test.go | 3 + .../store/sqlstore/property_field_store.go | 321 +++++++++++ .../sqlstore/property_field_store_test.go | 14 + .../store/sqlstore/property_group_store.go | 78 +++ .../sqlstore/property_group_store_test.go | 14 + .../store/sqlstore/property_value_store.go | 332 +++++++++++ .../sqlstore/property_value_store_test.go | 14 + server/channels/store/sqlstore/store.go | 18 + server/channels/store/store.go | 27 + .../storetest/mocks/PropertyFieldStore.go | 197 +++++++ .../storetest/mocks/PropertyGroupStore.go | 89 +++ .../storetest/mocks/PropertyValueStore.go | 215 +++++++ .../channels/store/storetest/mocks/Store.go | 60 ++ .../store/storetest/property_field_store.go | 461 +++++++++++++++ .../store/storetest/property_group_store.go | 36 ++ .../store/storetest/property_value_store.go | 535 ++++++++++++++++++ server/channels/store/storetest/store.go | 6 + .../channels/store/timerlayer/timerlayer.go | 273 +++++++++ server/channels/testlib/store.go | 7 + server/i18n/en.json | 8 + server/public/model/property_field.go | 83 +++ server/public/model/property_group.go | 15 + server/public/model/property_value.go | 71 +++ 35 files changed, 3793 insertions(+) create mode 100644 server/channels/app/properties/property_field.go create mode 100644 server/channels/app/properties/property_group.go create mode 100644 server/channels/app/properties/property_value.go create mode 100644 server/channels/app/properties/service.go create mode 100644 server/channels/db/migrations/mysql/000129_add_property_system_architecture.down.sql create mode 100644 server/channels/db/migrations/mysql/000129_add_property_system_architecture.up.sql create mode 100644 server/channels/db/migrations/postgres/000129_add_property_system_architecture.down.sql create mode 100644 server/channels/db/migrations/postgres/000129_add_property_system_architecture.up.sql create mode 100644 server/channels/store/sqlstore/property_field_store.go create mode 100644 server/channels/store/sqlstore/property_field_store_test.go create mode 100644 server/channels/store/sqlstore/property_group_store.go create mode 100644 server/channels/store/sqlstore/property_group_store_test.go create mode 100644 server/channels/store/sqlstore/property_value_store.go create mode 100644 server/channels/store/sqlstore/property_value_store_test.go create mode 100644 server/channels/store/storetest/mocks/PropertyFieldStore.go create mode 100644 server/channels/store/storetest/mocks/PropertyGroupStore.go create mode 100644 server/channels/store/storetest/mocks/PropertyValueStore.go create mode 100644 server/channels/store/storetest/property_field_store.go create mode 100644 server/channels/store/storetest/property_group_store.go create mode 100644 server/channels/store/storetest/property_value_store.go create mode 100644 server/public/model/property_field.go create mode 100644 server/public/model/property_group.go create mode 100644 server/public/model/property_value.go diff --git a/server/channels/app/properties/property_field.go b/server/channels/app/properties/property_field.go new file mode 100644 index 00000000000..d93b81547ec --- /dev/null +++ b/server/channels/app/properties/property_field.go @@ -0,0 +1,44 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package properties + +import ( + "github.com/mattermost/mattermost/server/public/model" +) + +func (ps *PropertyService) CreatePropertyField(field *model.PropertyField) (*model.PropertyField, error) { + return ps.fieldStore.Create(field) +} + +func (ps *PropertyService) GetPropertyField(id string) (*model.PropertyField, error) { + return ps.fieldStore.Get(id) +} + +func (ps *PropertyService) GetPropertyFields(ids []string) ([]*model.PropertyField, error) { + return ps.fieldStore.GetMany(ids) +} + +func (ps *PropertyService) SearchPropertyFields(opts model.PropertyFieldSearchOpts) ([]*model.PropertyField, error) { + return ps.fieldStore.SearchPropertyFields(opts) +} + +func (ps *PropertyService) UpdatePropertyField(field *model.PropertyField) (*model.PropertyField, error) { + fields, err := ps.UpdatePropertyFields([]*model.PropertyField{field}) + if err != nil { + return nil, err + } + + return fields[0], nil +} + +func (ps *PropertyService) UpdatePropertyFields(fields []*model.PropertyField) ([]*model.PropertyField, error) { + return ps.fieldStore.Update(fields) +} + +func (ps *PropertyService) DeletePropertyField(id string) error { + if err := ps.valueStore.DeleteForField(id); err != nil { + return err + } + return ps.fieldStore.Delete(id) +} diff --git a/server/channels/app/properties/property_group.go b/server/channels/app/properties/property_group.go new file mode 100644 index 00000000000..b9fa938ee7b --- /dev/null +++ b/server/channels/app/properties/property_group.go @@ -0,0 +1,16 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package properties + +import ( + "github.com/mattermost/mattermost/server/public/model" +) + +func (ps *PropertyService) RegisterPropertyGroup(name string) (*model.PropertyGroup, error) { + return ps.groupStore.Register(name) +} + +func (ps *PropertyService) GetPropertyGroup(name string) (*model.PropertyGroup, error) { + return ps.groupStore.Get(name) +} diff --git a/server/channels/app/properties/property_value.go b/server/channels/app/properties/property_value.go new file mode 100644 index 00000000000..7082ace2d78 --- /dev/null +++ b/server/channels/app/properties/property_value.go @@ -0,0 +1,41 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package properties + +import ( + "github.com/mattermost/mattermost/server/public/model" +) + +func (ps *PropertyService) CreatePropertyValue(value *model.PropertyValue) (*model.PropertyValue, error) { + return ps.valueStore.Create(value) +} + +func (ps *PropertyService) GetPropertyValue(id string) (*model.PropertyValue, error) { + return ps.valueStore.Get(id) +} + +func (ps *PropertyService) GetPropertyValues(ids []string) ([]*model.PropertyValue, error) { + return ps.valueStore.GetMany(ids) +} + +func (ps *PropertyService) SearchPropertyValues(opts model.PropertyValueSearchOpts) ([]*model.PropertyValue, error) { + return ps.valueStore.SearchPropertyValues(opts) +} + +func (ps *PropertyService) UpdatePropertyValue(value *model.PropertyValue) (*model.PropertyValue, error) { + values, err := ps.UpdatePropertyValues([]*model.PropertyValue{value}) + if err != nil { + return nil, err + } + + return values[0], nil +} + +func (ps *PropertyService) UpdatePropertyValues(values []*model.PropertyValue) ([]*model.PropertyValue, error) { + return ps.valueStore.Update(values) +} + +func (ps *PropertyService) DeletePropertyValue(id string) error { + return ps.valueStore.Delete(id) +} diff --git a/server/channels/app/properties/service.go b/server/channels/app/properties/service.go new file mode 100644 index 00000000000..f736d4efb11 --- /dev/null +++ b/server/channels/app/properties/service.go @@ -0,0 +1,41 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package properties + +import ( + "errors" + + "github.com/mattermost/mattermost/server/v8/channels/store" +) + +type PropertyService struct { + groupStore store.PropertyGroupStore + fieldStore store.PropertyFieldStore + valueStore store.PropertyValueStore +} + +type ServiceConfig struct { + PropertyGroupStore store.PropertyGroupStore + PropertyFieldStore store.PropertyFieldStore + PropertyValueStore store.PropertyValueStore +} + +func New(c ServiceConfig) (*PropertyService, error) { + if err := c.validate(); err != nil { + return nil, err + } + + return &PropertyService{ + groupStore: c.PropertyGroupStore, + fieldStore: c.PropertyFieldStore, + valueStore: c.PropertyValueStore, + }, nil +} + +func (c *ServiceConfig) validate() error { + if c.PropertyGroupStore == nil || c.PropertyFieldStore == nil || c.PropertyValueStore == nil { + return errors.New("required parameters are not provided") + } + return nil +} diff --git a/server/channels/app/server.go b/server/channels/app/server.go index 40a7d08524c..f1b2f6a1cae 100644 --- a/server/channels/app/server.go +++ b/server/channels/app/server.go @@ -35,6 +35,7 @@ import ( "github.com/mattermost/mattermost/server/public/shared/timezones" "github.com/mattermost/mattermost/server/v8/channels/app/email" "github.com/mattermost/mattermost/server/v8/channels/app/platform" + "github.com/mattermost/mattermost/server/v8/channels/app/properties" "github.com/mattermost/mattermost/server/v8/channels/app/teams" "github.com/mattermost/mattermost/server/v8/channels/app/users" "github.com/mattermost/mattermost/server/v8/channels/audit" @@ -136,6 +137,7 @@ type Server struct { telemetryService *telemetry.TelemetryService userService *users.UserService teamService *teams.TeamService + propertyService *properties.PropertyService serviceMux sync.RWMutex remoteClusterService remotecluster.RemoteClusterServiceIFace @@ -239,6 +241,15 @@ func NewServer(options ...Option) (*Server, error) { return nil, errors.Wrapf(err, "unable to create teams service") } + s.propertyService, err = properties.New(properties.ServiceConfig{ + PropertyGroupStore: s.Store().PropertyGroup(), + PropertyFieldStore: s.Store().PropertyField(), + PropertyValueStore: s.Store().PropertyValue(), + }) + if err != nil { + return nil, errors.Wrapf(err, "unable to create properties service") + } + // It is important to initialize the hub only after the global logger is set // to avoid race conditions while logging from inside the hub. // Step 4: Start platform diff --git a/server/channels/db/migrations/migrations.list b/server/channels/db/migrations/migrations.list index dc3b4b1f5f8..827f3664ff4 100644 --- a/server/channels/db/migrations/migrations.list +++ b/server/channels/db/migrations/migrations.list @@ -253,6 +253,8 @@ channels/db/migrations/mysql/000127_add_mfa_used_ts_to_users.down.sql channels/db/migrations/mysql/000127_add_mfa_used_ts_to_users.up.sql channels/db/migrations/mysql/000128_create_scheduled_posts.down.sql channels/db/migrations/mysql/000128_create_scheduled_posts.up.sql +channels/db/migrations/mysql/000129_add_property_system_architecture.down.sql +channels/db/migrations/mysql/000129_add_property_system_architecture.up.sql channels/db/migrations/postgres/000001_create_teams.down.sql channels/db/migrations/postgres/000001_create_teams.up.sql channels/db/migrations/postgres/000002_create_team_members.down.sql @@ -507,3 +509,5 @@ channels/db/migrations/postgres/000127_add_mfa_used_ts_to_users.down.sql channels/db/migrations/postgres/000127_add_mfa_used_ts_to_users.up.sql channels/db/migrations/postgres/000128_create_scheduled_posts.down.sql channels/db/migrations/postgres/000128_create_scheduled_posts.up.sql +channels/db/migrations/postgres/000129_add_property_system_architecture.down.sql +channels/db/migrations/postgres/000129_add_property_system_architecture.up.sql diff --git a/server/channels/db/migrations/mysql/000129_add_property_system_architecture.down.sql b/server/channels/db/migrations/mysql/000129_add_property_system_architecture.down.sql new file mode 100644 index 00000000000..2a84e5a456c --- /dev/null +++ b/server/channels/db/migrations/mysql/000129_add_property_system_architecture.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS PropertyGroups; +DROP TABLE IF EXISTS PropertyFields; +DROP TABLE IF EXISTS PropertyValues; diff --git a/server/channels/db/migrations/mysql/000129_add_property_system_architecture.up.sql b/server/channels/db/migrations/mysql/000129_add_property_system_architecture.up.sql new file mode 100644 index 00000000000..531aae9bd3e --- /dev/null +++ b/server/channels/db/migrations/mysql/000129_add_property_system_architecture.up.sql @@ -0,0 +1,47 @@ +CREATE TABLE IF NOT EXISTS PropertyGroups ( + ID varchar(26) PRIMARY KEY, + Name varchar(64) NOT NULL, + UNIQUE(Name) +); + +CREATE TABLE IF NOT EXISTS PropertyFields ( + ID varchar(26) PRIMARY KEY, + GroupID varchar(26) NOT NULL, + Name varchar(255) NOT NULL, + Type enum('text', 'select', 'multiselect', 'date', 'user', 'multiuser'), + Attrs json, + TargetID varchar(255), + TargetType varchar(255), + CreateAt bigint(20), + UpdateAt bigint(20), + DeleteAt bigint(20), + UNIQUE(GroupID, TargetID, Name, DeleteAt) +); + +CREATE TABLE IF NOT EXISTS PropertyValues ( + ID varchar(26) PRIMARY KEY, + TargetID varchar(255) NOT NULL, + TargetType varchar(255) NOT NULL, + GroupID varchar(26) NOT NULL, + FieldID varchar(26) NOT NULL, + Value json, + CreateAt bigint(20), + UpdateAt bigint(20), + DeleteAt bigint(20), + UNIQUE(GroupID, TargetID, FieldID, DeleteAt) +); + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'PropertyValues' + AND table_schema = DATABASE() + AND index_name = 'idx_propertyvalues_targetid_groupid' + ) > 0, + 'SELECT 1', + 'CREATE INDEX idx_propertyvalues_targetid_groupid ON PropertyValues (TargetID, GroupID);' +)); + +PREPARE createIndexIfNotExists FROM @preparedStatement; +EXECUTE createIndexIfNotExists; +DEALLOCATE PREPARE createIndexIfNotExists; diff --git a/server/channels/db/migrations/postgres/000129_add_property_system_architecture.down.sql b/server/channels/db/migrations/postgres/000129_add_property_system_architecture.down.sql new file mode 100644 index 00000000000..2a84e5a456c --- /dev/null +++ b/server/channels/db/migrations/postgres/000129_add_property_system_architecture.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS PropertyGroups; +DROP TABLE IF EXISTS PropertyFields; +DROP TABLE IF EXISTS PropertyValues; diff --git a/server/channels/db/migrations/postgres/000129_add_property_system_architecture.up.sql b/server/channels/db/migrations/postgres/000129_add_property_system_architecture.up.sql new file mode 100644 index 00000000000..f1403b26f48 --- /dev/null +++ b/server/channels/db/migrations/postgres/000129_add_property_system_architecture.up.sql @@ -0,0 +1,55 @@ +CREATE TABLE IF NOT EXISTS PropertyGroups ( + ID varchar(26) PRIMARY KEY, + Name varchar(64) NOT NULL, + UNIQUE(Name) +); + +DO +$$ +BEGIN + IF NOT EXISTS (SELECT * FROM pg_type typ + INNER JOIN pg_namespace nsp ON nsp.oid = typ.typnamespace + WHERE nsp.nspname = current_schema() + AND typ.typname = 'property_field_type') THEN + CREATE TYPE property_field_type AS ENUM ( + 'text', + 'select', + 'multiselect', + 'date', + 'user', + 'multiuser' + ); + END IF; +END; +$$ +LANGUAGE plpgsql; + +CREATE TABLE IF NOT EXISTS PropertyFields ( + ID varchar(26) PRIMARY KEY, + GroupID varchar(26) NOT NULL, + Name varchar(255) NOT NULL, + Type property_field_type, + Attrs jsonb, + TargetID varchar(255), + TargetType varchar(255), + CreateAt bigint NOT NULL, + UpdateAt bigint NOT NULL, + DeleteAt bigint NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_propertyfields_unique ON PropertyFields (GroupID, TargetID, Name) WHERE DeleteAt = 0; + +CREATE TABLE IF NOT EXISTS PropertyValues ( + ID varchar(26) PRIMARY KEY, + TargetID varchar(255) NOT NULL, + TargetType varchar(255) NOT NULL, + GroupID varchar(26) NOT NULL, + FieldID varchar(26) NOT NULL, + Value jsonb NOT NULL, + CreateAt bigint NOT NULL, + UpdateAt bigint NOT NULL, + DeleteAt bigint NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_propertyvalues_unique ON PropertyValues (GroupID, TargetID, FieldID) WHERE DeleteAt = 0; +CREATE INDEX IF NOT EXISTS idx_propertyvalues_targetid_groupid ON PropertyValues (TargetID, GroupID); diff --git a/server/channels/store/opentracinglayer/opentracinglayer.go b/server/channels/store/opentracinglayer/opentracinglayer.go index 2a1da763751..31681c08aa8 100644 --- a/server/channels/store/opentracinglayer/opentracinglayer.go +++ b/server/channels/store/opentracinglayer/opentracinglayer.go @@ -46,6 +46,9 @@ type OpenTracingLayer struct { PostPriorityStore store.PostPriorityStore PreferenceStore store.PreferenceStore ProductNoticesStore store.ProductNoticesStore + PropertyFieldStore store.PropertyFieldStore + PropertyGroupStore store.PropertyGroupStore + PropertyValueStore store.PropertyValueStore ReactionStore store.ReactionStore RemoteClusterStore store.RemoteClusterStore RetentionPolicyStore store.RetentionPolicyStore @@ -175,6 +178,18 @@ func (s *OpenTracingLayer) ProductNotices() store.ProductNoticesStore { return s.ProductNoticesStore } +func (s *OpenTracingLayer) PropertyField() store.PropertyFieldStore { + return s.PropertyFieldStore +} + +func (s *OpenTracingLayer) PropertyGroup() store.PropertyGroupStore { + return s.PropertyGroupStore +} + +func (s *OpenTracingLayer) PropertyValue() store.PropertyValueStore { + return s.PropertyValueStore +} + func (s *OpenTracingLayer) Reaction() store.ReactionStore { return s.ReactionStore } @@ -386,6 +401,21 @@ type OpenTracingLayerProductNoticesStore struct { Root *OpenTracingLayer } +type OpenTracingLayerPropertyFieldStore struct { + store.PropertyFieldStore + Root *OpenTracingLayer +} + +type OpenTracingLayerPropertyGroupStore struct { + store.PropertyGroupStore + Root *OpenTracingLayer +} + +type OpenTracingLayerPropertyValueStore struct { + store.PropertyValueStore + Root *OpenTracingLayer +} + type OpenTracingLayerReactionStore struct { store.ReactionStore Root *OpenTracingLayer @@ -7730,6 +7760,276 @@ func (s *OpenTracingLayerProductNoticesStore) View(userID string, notices []stri return err } +func (s *OpenTracingLayerPropertyFieldStore) Create(field *model.PropertyField) (*model.PropertyField, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PropertyFieldStore.Create") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PropertyFieldStore.Create(field) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerPropertyFieldStore) Delete(id string) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PropertyFieldStore.Delete") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.PropertyFieldStore.Delete(id) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + +func (s *OpenTracingLayerPropertyFieldStore) Get(id string) (*model.PropertyField, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PropertyFieldStore.Get") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PropertyFieldStore.Get(id) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerPropertyFieldStore) GetMany(ids []string) ([]*model.PropertyField, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PropertyFieldStore.GetMany") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PropertyFieldStore.GetMany(ids) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerPropertyFieldStore) SearchPropertyFields(opts model.PropertyFieldSearchOpts) ([]*model.PropertyField, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PropertyFieldStore.SearchPropertyFields") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PropertyFieldStore.SearchPropertyFields(opts) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerPropertyFieldStore) Update(field []*model.PropertyField) ([]*model.PropertyField, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PropertyFieldStore.Update") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PropertyFieldStore.Update(field) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerPropertyGroupStore) Get(name string) (*model.PropertyGroup, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PropertyGroupStore.Get") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PropertyGroupStore.Get(name) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerPropertyGroupStore) Register(name string) (*model.PropertyGroup, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PropertyGroupStore.Register") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PropertyGroupStore.Register(name) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerPropertyValueStore) Create(value *model.PropertyValue) (*model.PropertyValue, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PropertyValueStore.Create") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PropertyValueStore.Create(value) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerPropertyValueStore) Delete(id string) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PropertyValueStore.Delete") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.PropertyValueStore.Delete(id) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + +func (s *OpenTracingLayerPropertyValueStore) DeleteForField(id string) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PropertyValueStore.DeleteForField") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.PropertyValueStore.DeleteForField(id) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + +func (s *OpenTracingLayerPropertyValueStore) Get(id string) (*model.PropertyValue, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PropertyValueStore.Get") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PropertyValueStore.Get(id) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerPropertyValueStore) GetMany(ids []string) ([]*model.PropertyValue, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PropertyValueStore.GetMany") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PropertyValueStore.GetMany(ids) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerPropertyValueStore) SearchPropertyValues(opts model.PropertyValueSearchOpts) ([]*model.PropertyValue, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PropertyValueStore.SearchPropertyValues") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PropertyValueStore.SearchPropertyValues(opts) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerPropertyValueStore) Update(field []*model.PropertyValue) ([]*model.PropertyValue, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PropertyValueStore.Update") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PropertyValueStore.Update(field) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerReactionStore) BulkGetForPosts(postIds []string) ([]*model.Reaction, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ReactionStore.BulkGetForPosts") @@ -13853,6 +14153,9 @@ func New(childStore store.Store, ctx context.Context) *OpenTracingLayer { newStore.PostPriorityStore = &OpenTracingLayerPostPriorityStore{PostPriorityStore: childStore.PostPriority(), Root: &newStore} newStore.PreferenceStore = &OpenTracingLayerPreferenceStore{PreferenceStore: childStore.Preference(), Root: &newStore} newStore.ProductNoticesStore = &OpenTracingLayerProductNoticesStore{ProductNoticesStore: childStore.ProductNotices(), Root: &newStore} + newStore.PropertyFieldStore = &OpenTracingLayerPropertyFieldStore{PropertyFieldStore: childStore.PropertyField(), Root: &newStore} + newStore.PropertyGroupStore = &OpenTracingLayerPropertyGroupStore{PropertyGroupStore: childStore.PropertyGroup(), Root: &newStore} + newStore.PropertyValueStore = &OpenTracingLayerPropertyValueStore{PropertyValueStore: childStore.PropertyValue(), Root: &newStore} newStore.ReactionStore = &OpenTracingLayerReactionStore{ReactionStore: childStore.Reaction(), Root: &newStore} newStore.RemoteClusterStore = &OpenTracingLayerRemoteClusterStore{RemoteClusterStore: childStore.RemoteCluster(), Root: &newStore} newStore.RetentionPolicyStore = &OpenTracingLayerRetentionPolicyStore{RetentionPolicyStore: childStore.RetentionPolicy(), Root: &newStore} diff --git a/server/channels/store/retrylayer/retrylayer.go b/server/channels/store/retrylayer/retrylayer.go index c931566e0e5..71a1096aa71 100644 --- a/server/channels/store/retrylayer/retrylayer.go +++ b/server/channels/store/retrylayer/retrylayer.go @@ -50,6 +50,9 @@ type RetryLayer struct { PostPriorityStore store.PostPriorityStore PreferenceStore store.PreferenceStore ProductNoticesStore store.ProductNoticesStore + PropertyFieldStore store.PropertyFieldStore + PropertyGroupStore store.PropertyGroupStore + PropertyValueStore store.PropertyValueStore ReactionStore store.ReactionStore RemoteClusterStore store.RemoteClusterStore RetentionPolicyStore store.RetentionPolicyStore @@ -179,6 +182,18 @@ func (s *RetryLayer) ProductNotices() store.ProductNoticesStore { return s.ProductNoticesStore } +func (s *RetryLayer) PropertyField() store.PropertyFieldStore { + return s.PropertyFieldStore +} + +func (s *RetryLayer) PropertyGroup() store.PropertyGroupStore { + return s.PropertyGroupStore +} + +func (s *RetryLayer) PropertyValue() store.PropertyValueStore { + return s.PropertyValueStore +} + func (s *RetryLayer) Reaction() store.ReactionStore { return s.ReactionStore } @@ -390,6 +405,21 @@ type RetryLayerProductNoticesStore struct { Root *RetryLayer } +type RetryLayerPropertyFieldStore struct { + store.PropertyFieldStore + Root *RetryLayer +} + +type RetryLayerPropertyGroupStore struct { + store.PropertyGroupStore + Root *RetryLayer +} + +type RetryLayerPropertyValueStore struct { + store.PropertyValueStore + Root *RetryLayer +} + type RetryLayerReactionStore struct { store.ReactionStore Root *RetryLayer @@ -8793,6 +8823,321 @@ func (s *RetryLayerProductNoticesStore) View(userID string, notices []string) er } +func (s *RetryLayerPropertyFieldStore) Create(field *model.PropertyField) (*model.PropertyField, error) { + + tries := 0 + for { + result, err := s.PropertyFieldStore.Create(field) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPropertyFieldStore) Delete(id string) error { + + tries := 0 + for { + err := s.PropertyFieldStore.Delete(id) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPropertyFieldStore) Get(id string) (*model.PropertyField, error) { + + tries := 0 + for { + result, err := s.PropertyFieldStore.Get(id) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPropertyFieldStore) GetMany(ids []string) ([]*model.PropertyField, error) { + + tries := 0 + for { + result, err := s.PropertyFieldStore.GetMany(ids) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPropertyFieldStore) SearchPropertyFields(opts model.PropertyFieldSearchOpts) ([]*model.PropertyField, error) { + + tries := 0 + for { + result, err := s.PropertyFieldStore.SearchPropertyFields(opts) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPropertyFieldStore) Update(field []*model.PropertyField) ([]*model.PropertyField, error) { + + tries := 0 + for { + result, err := s.PropertyFieldStore.Update(field) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPropertyGroupStore) Get(name string) (*model.PropertyGroup, error) { + + tries := 0 + for { + result, err := s.PropertyGroupStore.Get(name) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPropertyGroupStore) Register(name string) (*model.PropertyGroup, error) { + + tries := 0 + for { + result, err := s.PropertyGroupStore.Register(name) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPropertyValueStore) Create(value *model.PropertyValue) (*model.PropertyValue, error) { + + tries := 0 + for { + result, err := s.PropertyValueStore.Create(value) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPropertyValueStore) Delete(id string) error { + + tries := 0 + for { + err := s.PropertyValueStore.Delete(id) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPropertyValueStore) DeleteForField(id string) error { + + tries := 0 + for { + err := s.PropertyValueStore.DeleteForField(id) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPropertyValueStore) Get(id string) (*model.PropertyValue, error) { + + tries := 0 + for { + result, err := s.PropertyValueStore.Get(id) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPropertyValueStore) GetMany(ids []string) ([]*model.PropertyValue, error) { + + tries := 0 + for { + result, err := s.PropertyValueStore.GetMany(ids) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPropertyValueStore) SearchPropertyValues(opts model.PropertyValueSearchOpts) ([]*model.PropertyValue, error) { + + tries := 0 + for { + result, err := s.PropertyValueStore.SearchPropertyValues(opts) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerPropertyValueStore) Update(field []*model.PropertyValue) ([]*model.PropertyValue, error) { + + tries := 0 + for { + result, err := s.PropertyValueStore.Update(field) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerReactionStore) BulkGetForPosts(postIds []string) ([]*model.Reaction, error) { tries := 0 @@ -15806,6 +16151,9 @@ func New(childStore store.Store) *RetryLayer { newStore.PostPriorityStore = &RetryLayerPostPriorityStore{PostPriorityStore: childStore.PostPriority(), Root: &newStore} newStore.PreferenceStore = &RetryLayerPreferenceStore{PreferenceStore: childStore.Preference(), Root: &newStore} newStore.ProductNoticesStore = &RetryLayerProductNoticesStore{ProductNoticesStore: childStore.ProductNotices(), Root: &newStore} + newStore.PropertyFieldStore = &RetryLayerPropertyFieldStore{PropertyFieldStore: childStore.PropertyField(), Root: &newStore} + newStore.PropertyGroupStore = &RetryLayerPropertyGroupStore{PropertyGroupStore: childStore.PropertyGroup(), Root: &newStore} + newStore.PropertyValueStore = &RetryLayerPropertyValueStore{PropertyValueStore: childStore.PropertyValue(), Root: &newStore} newStore.ReactionStore = &RetryLayerReactionStore{ReactionStore: childStore.Reaction(), Root: &newStore} newStore.RemoteClusterStore = &RetryLayerRemoteClusterStore{RemoteClusterStore: childStore.RemoteCluster(), Root: &newStore} newStore.RetentionPolicyStore = &RetryLayerRetentionPolicyStore{RetentionPolicyStore: childStore.RetentionPolicy(), Root: &newStore} diff --git a/server/channels/store/retrylayer/retrylayer_test.go b/server/channels/store/retrylayer/retrylayer_test.go index 50844e7cb76..7e4a319485e 100644 --- a/server/channels/store/retrylayer/retrylayer_test.go +++ b/server/channels/store/retrylayer/retrylayer_test.go @@ -63,6 +63,9 @@ func genStore() *mocks.Store { mock.On("DesktopTokens").Return(&mocks.DesktopTokensStore{}) mock.On("ChannelBookmark").Return(&mocks.ChannelBookmarkStore{}) mock.On("ScheduledPost").Return(&mocks.ScheduledPostStore{}) + mock.On("PropertyField").Return(&mocks.PropertyFieldStore{}) + mock.On("PropertyGroup").Return(&mocks.PropertyGroupStore{}) + mock.On("PropertyValue").Return(&mocks.PropertyValueStore{}) return mock } diff --git a/server/channels/store/sqlstore/property_field_store.go b/server/channels/store/sqlstore/property_field_store.go new file mode 100644 index 00000000000..e3023a72cd4 --- /dev/null +++ b/server/channels/store/sqlstore/property_field_store.go @@ -0,0 +1,321 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "database/sql" + "encoding/json" + "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" +) + +func (s *SqlPropertyFieldStore) propertyFieldToInsertMap(field *model.PropertyField) (map[string]any, error) { + attrsJSON, err := json.Marshal(field.Attrs) + if err != nil { + return nil, errors.Wrap(err, "property_field_to_insert_map_marshal_attrs") + } + if s.IsBinaryParamEnabled() { + attrsJSON = AppendBinaryFlag(attrsJSON) + } + + return map[string]any{ + "ID": field.ID, + "GroupID": field.GroupID, + "Name": field.Name, + "Type": field.Type, + "Attrs": attrsJSON, + "TargetID": field.TargetID, + "TargetType": field.TargetType, + "CreateAt": field.CreateAt, + "UpdateAt": field.UpdateAt, + "DeleteAt": field.DeleteAt, + }, nil +} + +func (s *SqlPropertyFieldStore) propertyFieldToUpdateMap(field *model.PropertyField) (map[string]any, error) { + attrsJSON, err := json.Marshal(field.Attrs) + if err != nil { + return nil, errors.Wrap(err, "property_field_to_update_map_marshal_attrs") + } + if s.IsBinaryParamEnabled() { + attrsJSON = AppendBinaryFlag(attrsJSON) + } + + return map[string]any{ + "Name": field.Name, + "Type": field.Type, + "Attrs": attrsJSON, + "TargetID": field.TargetID, + "TargetType": field.TargetType, + "UpdateAt": field.UpdateAt, + "DeleteAt": field.DeleteAt, + }, nil +} + +func propertyFieldsFromRows(rows *sql.Rows) ([]*model.PropertyField, error) { + results := []*model.PropertyField{} + + for rows.Next() { + var field model.PropertyField + var attrsJSON string + + err := rows.Scan( + &field.ID, + &field.GroupID, + &field.Name, + &field.Type, + &attrsJSON, + &field.TargetID, + &field.TargetType, + &field.CreateAt, + &field.UpdateAt, + &field.DeleteAt, + ) + if err != nil { + return nil, err + } + + if err := json.Unmarshal([]byte(attrsJSON), &field.Attrs); err != nil { + return nil, errors.Wrap(err, "property_fields_from_rows_unmarshal_attrs") + } + + results = append(results, &field) + } + + return results, nil +} + +func propertyFieldFromRows(rows *sql.Rows) (*model.PropertyField, error) { + fields, err := propertyFieldsFromRows(rows) + if err != nil { + return nil, err + } + + if len(fields) > 0 { + return fields[0], nil + } + + return nil, sql.ErrNoRows +} + +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", "CreateAt", "UpdateAt", "DeleteAt"). + 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.IsValid(); err != nil { + return nil, errors.Wrap(err, "property_field_create_isvalid") + } + + insertMap, err := s.propertyFieldToInsertMap(field) + if err != nil { + return nil, err + } + + builder := s.getQueryBuilder(). + Insert("PropertyFields"). + SetMap(insertMap) + + if _, err := s.GetMaster().ExecBuilder(builder); err != nil { + return nil, errors.Wrap(err, "property_field_create_insert") + } + + return field, nil +} + +func (s *SqlPropertyFieldStore) Get(id string) (*model.PropertyField, error) { + queryString, args, err := s.tableSelectQuery. + Where(sq.Eq{"id": id}). + ToSql() + if err != nil { + return nil, errors.Wrap(err, "property_field_get_tosql") + } + + rows, err := s.GetReplica().Query(queryString, args...) + if err != nil { + return nil, errors.Wrap(err, "property_field_get_select") + } + defer rows.Close() + + field, err := propertyFieldFromRows(rows) + if err != nil { + return nil, errors.Wrap(err, "property_field_get_propertyfieldfromrows") + } + + return field, nil +} + +func (s *SqlPropertyFieldStore) GetMany(ids []string) ([]*model.PropertyField, error) { + queryString, args, err := s.tableSelectQuery. + Where(sq.Eq{"id": ids}). + ToSql() + if err != nil { + return nil, errors.Wrap(err, "property_field_get_many_tosql") + } + + rows, err := s.GetReplica().Query(queryString, args...) + if err != nil { + return nil, errors.Wrap(err, "property_field_get_many_query") + } + defer rows.Close() + + fields, err := propertyFieldsFromRows(rows) + if err != nil { + return nil, errors.Wrap(err, "property_field_get_many_propertyfieldfromrows") + } + + if len(fields) < len(ids) { + return nil, fmt.Errorf("missmatch results: got %d results of the %d ids passed", len(fields), len(ids)) + } + + return fields, nil +} + +func (s *SqlPropertyFieldStore) SearchPropertyFields(opts model.PropertyFieldSearchOpts) ([]*model.PropertyField, error) { + if opts.Page < 0 { + return nil, errors.New("page must be positive integer") + } + + if opts.PerPage < 1 { + return nil, errors.New("per page must be positive integer greater than zero") + } + + query := s.tableSelectQuery. + OrderBy("CreateAt ASC"). + Offset(uint64(opts.Page * opts.PerPage)). + Limit(uint64(opts.PerPage)) + + if !opts.IncludeDeleted { + query = query.Where(sq.Eq{"DeleteAt": 0}) + } + + if opts.GroupID != "" { + query = query.Where(sq.Eq{"GroupID": opts.GroupID}) + } + + if opts.TargetType != "" { + query = query.Where(sq.Eq{"TargetType": opts.TargetType}) + } + + if opts.TargetID != "" { + query = query.Where(sq.Eq{"TargetID": opts.TargetID}) + } + + queryString, args, err := query.ToSql() + if err != nil { + return nil, errors.Wrap(err, "property_field_search_tosql") + } + + rows, err := s.GetReplica().Query(queryString, args...) + if err != nil { + return nil, errors.Wrap(err, "property_field_search_query") + } + defer rows.Close() + + fields, err := propertyFieldsFromRows(rows) + if err != nil { + return nil, errors.Wrap(err, "property_field_search_propertyfieldfromrows") + } + + return fields, nil +} + +func (s *SqlPropertyFieldStore) Update(fields []*model.PropertyField) (_ []*model.PropertyField, err error) { + if len(fields) == 0 { + return nil, nil + } + + transaction, err := s.GetMaster().Beginx() + if err != nil { + return nil, errors.Wrap(err, "property_field_update_begin_transaction") + } + defer finalizeTransactionX(transaction, &err) + + updateTime := model.GetMillis() + for _, field := range fields { + field.UpdateAt = updateTime + + if vErr := field.IsValid(); vErr != nil { + return nil, errors.Wrap(vErr, "property_field_update_isvalid") + } + + updateMap, err := s.propertyFieldToUpdateMap(field) + if err != nil { + return nil, err + } + + queryString, args, err := s.getQueryBuilder(). + Update("PropertyFields"). + SetMap(updateMap). + Where(sq.Eq{"id": field.ID}). + ToSql() + if err != nil { + return nil, errors.Wrap(err, "property_field_update_tosql") + } + + result, err := transaction.Exec(queryString, args...) + if err != nil { + return nil, errors.Wrapf(err, "failed to update property field with id: %s", field.ID) + } + + count, err := result.RowsAffected() + if err != nil { + return nil, errors.Wrap(err, "property_field_update_rowsaffected") + } + if count == 0 { + return nil, store.NewErrNotFound("PropertyField", field.ID) + } + } + + if err := transaction.Commit(); err != nil { + return nil, errors.Wrap(err, "property_field_update_commit") + } + + return fields, nil +} + +func (s *SqlPropertyFieldStore) Delete(id string) error { + builder := s.getQueryBuilder(). + Update("PropertyFields"). + Set("DeleteAt", model.GetMillis()). + Where(sq.Eq{"id": id}) + + 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 +} diff --git a/server/channels/store/sqlstore/property_field_store_test.go b/server/channels/store/sqlstore/property_field_store_test.go new file mode 100644 index 00000000000..1ef4cfd9c9f --- /dev/null +++ b/server/channels/store/sqlstore/property_field_store_test.go @@ -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) +} diff --git a/server/channels/store/sqlstore/property_group_store.go b/server/channels/store/sqlstore/property_group_store.go new file mode 100644 index 00000000000..44153b192cb --- /dev/null +++ b/server/channels/store/sqlstore/property_group_store.go @@ -0,0 +1,78 @@ +// 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"} + +type SqlPropertyGroupStore struct { + *SqlStore +} + +func newPropertyGroupStore(sqlStore *SqlStore) store.PropertyGroupStore { + return &SqlPropertyGroupStore{sqlStore} +} + +func (s *SqlPropertyGroupStore) Register(name string) (*model.PropertyGroup, error) { + if name == "" { + return nil, store.NewErrInvalidInput("PropertyGroup", "name", name) + } + + group := &model.PropertyGroup{Name: name} + group.PreSave() + + builder := s.getQueryBuilder(). + Insert("PropertyGroups"). + Columns("ID", "Name"). + Values(group.ID, group.Name) + + if s.DriverName() == model.DatabaseDriverMysql { + builder = builder.SuffixExpr(sq.Expr("ON DUPLICATE KEY UPDATE Name=Name")) + } else { + 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(name) + } + + return group, 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 +} diff --git a/server/channels/store/sqlstore/property_group_store_test.go b/server/channels/store/sqlstore/property_group_store_test.go new file mode 100644 index 00000000000..72f2087771c --- /dev/null +++ b/server/channels/store/sqlstore/property_group_store_test.go @@ -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) +} diff --git a/server/channels/store/sqlstore/property_value_store.go b/server/channels/store/sqlstore/property_value_store.go new file mode 100644 index 00000000000..a57604203f8 --- /dev/null +++ b/server/channels/store/sqlstore/property_value_store.go @@ -0,0 +1,332 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "database/sql" + "encoding/json" + "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" +) + +func (s *SqlPropertyValueStore) propertyValueToInsertMap(value *model.PropertyValue) (map[string]any, error) { + valueJSON, err := json.Marshal(value.Value) + if err != nil { + return nil, errors.Wrap(err, "property_value_to_insert_map_marshal_value") + } + if s.IsBinaryParamEnabled() { + valueJSON = AppendBinaryFlag(valueJSON) + } + + return map[string]any{ + "ID": value.ID, + "TargetID": value.TargetID, + "TargetType": value.TargetType, + "GroupID": value.GroupID, + "FieldID": value.FieldID, + "Value": valueJSON, + "CreateAt": value.CreateAt, + "UpdateAt": value.UpdateAt, + "DeleteAt": value.DeleteAt, + }, nil +} + +func (s *SqlPropertyValueStore) propertyValueToUpdateMap(value *model.PropertyValue) (map[string]any, error) { + valueJSON, err := json.Marshal(value.Value) + if err != nil { + return nil, errors.Wrap(err, "property_value_to_udpate_map_marshal_value") + } + if s.IsBinaryParamEnabled() { + valueJSON = AppendBinaryFlag(valueJSON) + } + + return map[string]any{ + "Value": valueJSON, + "UpdateAt": value.UpdateAt, + "DeleteAt": value.DeleteAt, + }, nil +} + +func propertyValuesFromRows(rows *sql.Rows) ([]*model.PropertyValue, error) { + results := []*model.PropertyValue{} + + for rows.Next() { + var value model.PropertyValue + var valueJSON string + + err := rows.Scan( + &value.ID, + &value.TargetID, + &value.TargetType, + &value.GroupID, + &value.FieldID, + &valueJSON, + &value.CreateAt, + &value.UpdateAt, + &value.DeleteAt, + ) + if err != nil { + return nil, err + } + + if err := json.Unmarshal([]byte(valueJSON), &value.Value); err != nil { + return nil, errors.Wrap(err, "property_values_from_rows_unmarshal_value") + } + + results = append(results, &value) + } + + return results, nil +} + +func propertyValueFromRows(rows *sql.Rows) (*model.PropertyValue, error) { + values, err := propertyValuesFromRows(rows) + if err != nil { + return nil, err + } + + if len(values) > 0 { + return values[0], nil + } + + return nil, sql.ErrNoRows +} + +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"). + 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") + } + + insertMap, err := s.propertyValueToInsertMap(value) + if err != nil { + return nil, err + } + + builder := s.getQueryBuilder(). + Insert("PropertyValues"). + SetMap(insertMap) + + if _, err := s.GetMaster().ExecBuilder(builder); err != nil { + return nil, errors.Wrap(err, "property_value_create_insert") + } + + return value, nil +} + +func (s *SqlPropertyValueStore) Get(id string) (*model.PropertyValue, error) { + queryString, args, err := s.tableSelectQuery. + Where(sq.Eq{"id": id}). + ToSql() + if err != nil { + return nil, errors.Wrap(err, "property_value_get_tosql") + } + + rows, err := s.GetReplica().Query(queryString, args...) + if err != nil { + return nil, errors.Wrap(err, "property_value_get_select") + } + defer rows.Close() + + value, err := propertyValueFromRows(rows) + if err != nil { + return nil, errors.Wrap(err, "property_value_get_propertyvaluefromrows") + } + + return value, nil +} + +func (s *SqlPropertyValueStore) GetMany(ids []string) ([]*model.PropertyValue, error) { + queryString, args, err := s.tableSelectQuery. + Where(sq.Eq{"id": ids}). + ToSql() + if err != nil { + return nil, errors.Wrap(err, "property_value_get_many_tosql") + } + + rows, err := s.GetReplica().Query(queryString, args...) + if err != nil { + return nil, errors.Wrap(err, "property_value_get_many_query") + } + defer rows.Close() + + values, err := propertyValuesFromRows(rows) + if err != nil { + return nil, errors.Wrap(err, "property_value_get_many_propertyvaluesfromrows") + } + + if len(values) < len(ids) { + return nil, fmt.Errorf("missmatch results: got %d results of the %d ids passed", len(values), len(ids)) + } + + return values, nil +} + +func (s *SqlPropertyValueStore) SearchPropertyValues(opts model.PropertyValueSearchOpts) ([]*model.PropertyValue, error) { + if opts.Page < 0 { + return nil, errors.New("page must be positive integer") + } + + if opts.PerPage < 1 { + return nil, errors.New("per page must be positive integer greater than zero") + } + + query := s.tableSelectQuery. + OrderBy("CreateAt ASC"). + Offset(uint64(opts.Page * opts.PerPage)). + Limit(uint64(opts.PerPage)) + + if !opts.IncludeDeleted { + query = query.Where(sq.Eq{"DeleteAt": 0}) + } + + if opts.GroupID != "" { + query = query.Where(sq.Eq{"GroupID": opts.GroupID}) + } + + if opts.TargetType != "" { + query = query.Where(sq.Eq{"TargetType": opts.TargetType}) + } + + if opts.TargetID != "" { + query = query.Where(sq.Eq{"TargetID": opts.TargetID}) + } + + if opts.FieldID != "" { + query = query.Where(sq.Eq{"FieldID": opts.FieldID}) + } + + queryString, args, err := query.ToSql() + if err != nil { + return nil, errors.Wrap(err, "property_value_search_tosql") + } + + rows, err := s.GetReplica().Query(queryString, args...) + if err != nil { + return nil, errors.Wrap(err, "property_value_search_query") + } + defer rows.Close() + + values, err := propertyValuesFromRows(rows) + if err != nil { + return nil, errors.Wrap(err, "property_value_search_propertyvaluesfromrows") + } + + return values, nil +} + +func (s *SqlPropertyValueStore) Update(values []*model.PropertyValue) (_ []*model.PropertyValue, err error) { + if len(values) == 0 { + return nil, nil + } + + transaction, err := s.GetMaster().Beginx() + if err != nil { + return nil, errors.Wrap(err, "property_value_update_begin_transaction") + } + defer finalizeTransactionX(transaction, &err) + + updateTime := model.GetMillis() + for _, value := range values { + value.UpdateAt = updateTime + + if err := value.IsValid(); err != nil { + return nil, errors.Wrap(err, "property_value_update_isvalid") + } + + updateMap, err := s.propertyValueToUpdateMap(value) + if err != nil { + return nil, err + } + + queryString, args, err := s.getQueryBuilder(). + Update("PropertyValues"). + SetMap(updateMap). + Where(sq.Eq{"id": value.ID}). + ToSql() + if err != nil { + return nil, errors.Wrap(err, "property_value_update_tosql") + } + + result, err := transaction.Exec(queryString, args...) + if err != nil { + return nil, errors.Wrapf(err, "failed to update property value with id: %s", value.ID) + } + + count, err := result.RowsAffected() + if err != nil { + return nil, errors.Wrap(err, "property_value_update_rowsaffected") + } + if count == 0 { + return nil, store.NewErrNotFound("PropertyValue", value.ID) + } + } + + if err := transaction.Commit(); err != nil { + return nil, errors.Wrap(err, "property_value_update_commit") + } + + return values, nil +} + +func (s *SqlPropertyValueStore) Delete(id string) error { + builder := s.getQueryBuilder(). + Update("PropertyValues"). + Set("DeleteAt", model.GetMillis()). + Where(sq.Eq{"id": id}) + + 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(fieldID string) error { + builder := s.getQueryBuilder(). + Update("PropertyValues"). + Set("DeleteAt", model.GetMillis()). + Where(sq.Eq{"FieldID": fieldID}) + + if _, err := s.GetMaster().ExecBuilder(builder); err != nil { + return errors.Wrap(err, "property_value_delete_for_field_exec") + } + + return nil +} diff --git a/server/channels/store/sqlstore/property_value_store_test.go b/server/channels/store/sqlstore/property_value_store_test.go new file mode 100644 index 00000000000..b041342640d --- /dev/null +++ b/server/channels/store/sqlstore/property_value_store_test.go @@ -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) +} diff --git a/server/channels/store/sqlstore/store.go b/server/channels/store/sqlstore/store.go index 88535e4ce63..33d47990365 100644 --- a/server/channels/store/sqlstore/store.go +++ b/server/channels/store/sqlstore/store.go @@ -115,6 +115,9 @@ type SqlStoreStores struct { desktopTokens store.DesktopTokensStore channelBookmarks store.ChannelBookmarkStore scheduledPost store.ScheduledPostStore + propertyGroup store.PropertyGroupStore + propertyField store.PropertyFieldStore + propertyValue store.PropertyValueStore } type SqlStore struct { @@ -257,6 +260,9 @@ func New(settings model.SqlSettings, logger mlog.LoggerIFace, metrics einterface store.stores.desktopTokens = newSqlDesktopTokensStore(store, metrics) store.stores.channelBookmarks = newSqlChannelBookmarkStore(store) store.stores.scheduledPost = newScheduledPostStore(store) + store.stores.propertyGroup = newPropertyGroupStore(store) + store.stores.propertyField = newPropertyFieldStore(store) + store.stores.propertyValue = newPropertyValueStore(store) store.stores.preference.(*SqlPreferenceStore).deleteUnusedFeatures() @@ -1060,6 +1066,18 @@ func (ss *SqlStore) ChannelBookmark() store.ChannelBookmarkStore { return ss.stores.channelBookmarks } +func (ss *SqlStore) PropertyGroup() store.PropertyGroupStore { + return ss.stores.propertyGroup +} + +func (ss *SqlStore) PropertyField() store.PropertyFieldStore { + return ss.stores.propertyField +} + +func (ss *SqlStore) PropertyValue() store.PropertyValueStore { + return ss.stores.propertyValue +} + func (ss *SqlStore) DropAllTables() { if ss.DriverName() == model.DatabaseDriverPostgres { ss.masterX.Exec(`DO diff --git a/server/channels/store/store.go b/server/channels/store/store.go index 4912e3cf1d5..55fe23c9a36 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -92,6 +92,9 @@ type Store interface { DesktopTokens() DesktopTokensStore ChannelBookmark() ChannelBookmarkStore ScheduledPost() ScheduledPostStore + PropertyGroup() PropertyGroupStore + PropertyField() PropertyFieldStore + PropertyValue() PropertyValueStore } type RetentionPolicyStore interface { @@ -1069,6 +1072,30 @@ type ScheduledPostStore interface { PermanentDeleteByUser(userId string) error } +type PropertyGroupStore interface { + Register(name string) (*model.PropertyGroup, error) + Get(name string) (*model.PropertyGroup, error) +} + +type PropertyFieldStore interface { + Create(field *model.PropertyField) (*model.PropertyField, error) + Get(id string) (*model.PropertyField, error) + GetMany(ids []string) ([]*model.PropertyField, error) + SearchPropertyFields(opts model.PropertyFieldSearchOpts) ([]*model.PropertyField, error) + Update(field []*model.PropertyField) ([]*model.PropertyField, error) + Delete(id string) error +} + +type PropertyValueStore interface { + Create(value *model.PropertyValue) (*model.PropertyValue, error) + Get(id string) (*model.PropertyValue, error) + GetMany(ids []string) ([]*model.PropertyValue, error) + SearchPropertyValues(opts model.PropertyValueSearchOpts) ([]*model.PropertyValue, error) + Update(field []*model.PropertyValue) ([]*model.PropertyValue, error) + Delete(id string) error + DeleteForField(id string) error +} + // ChannelSearchOpts contains options for searching channels. // // NotAssociatedToGroup will exclude channels that have associated, active GroupChannels records. diff --git a/server/channels/store/storetest/mocks/PropertyFieldStore.go b/server/channels/store/storetest/mocks/PropertyFieldStore.go new file mode 100644 index 00000000000..4afd0e81ffb --- /dev/null +++ b/server/channels/store/storetest/mocks/PropertyFieldStore.go @@ -0,0 +1,197 @@ +// Code generated by mockery v2.42.2. DO NOT EDIT. + +// Regenerate this file using `make store-mocks`. + +package mocks + +import ( + model "github.com/mattermost/mattermost/server/public/model" + mock "github.com/stretchr/testify/mock" +) + +// PropertyFieldStore is an autogenerated mock type for the PropertyFieldStore type +type PropertyFieldStore struct { + mock.Mock +} + +// Create provides a mock function with given fields: field +func (_m *PropertyFieldStore) Create(field *model.PropertyField) (*model.PropertyField, error) { + ret := _m.Called(field) + + if len(ret) == 0 { + panic("no return value specified for Create") + } + + var r0 *model.PropertyField + var r1 error + if rf, ok := ret.Get(0).(func(*model.PropertyField) (*model.PropertyField, error)); ok { + return rf(field) + } + if rf, ok := ret.Get(0).(func(*model.PropertyField) *model.PropertyField); ok { + r0 = rf(field) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.PropertyField) + } + } + + if rf, ok := ret.Get(1).(func(*model.PropertyField) error); ok { + r1 = rf(field) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Delete provides a mock function with given fields: id +func (_m *PropertyFieldStore) Delete(id string) error { + ret := _m.Called(id) + + if len(ret) == 0 { + panic("no return value specified for Delete") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Get provides a mock function with given fields: id +func (_m *PropertyFieldStore) Get(id string) (*model.PropertyField, error) { + ret := _m.Called(id) + + if len(ret) == 0 { + panic("no return value specified for Get") + } + + var r0 *model.PropertyField + var r1 error + if rf, ok := ret.Get(0).(func(string) (*model.PropertyField, error)); ok { + return rf(id) + } + if rf, ok := ret.Get(0).(func(string) *model.PropertyField); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.PropertyField) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetMany provides a mock function with given fields: ids +func (_m *PropertyFieldStore) GetMany(ids []string) ([]*model.PropertyField, error) { + ret := _m.Called(ids) + + if len(ret) == 0 { + panic("no return value specified for GetMany") + } + + var r0 []*model.PropertyField + var r1 error + if rf, ok := ret.Get(0).(func([]string) ([]*model.PropertyField, error)); ok { + return rf(ids) + } + if rf, ok := ret.Get(0).(func([]string) []*model.PropertyField); ok { + r0 = rf(ids) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.PropertyField) + } + } + + if rf, ok := ret.Get(1).(func([]string) error); ok { + r1 = rf(ids) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SearchPropertyFields provides a mock function with given fields: opts +func (_m *PropertyFieldStore) SearchPropertyFields(opts model.PropertyFieldSearchOpts) ([]*model.PropertyField, error) { + ret := _m.Called(opts) + + if len(ret) == 0 { + panic("no return value specified for SearchPropertyFields") + } + + var r0 []*model.PropertyField + var r1 error + if rf, ok := ret.Get(0).(func(model.PropertyFieldSearchOpts) ([]*model.PropertyField, error)); ok { + return rf(opts) + } + if rf, ok := ret.Get(0).(func(model.PropertyFieldSearchOpts) []*model.PropertyField); ok { + r0 = rf(opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.PropertyField) + } + } + + if rf, ok := ret.Get(1).(func(model.PropertyFieldSearchOpts) error); ok { + r1 = rf(opts) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Update provides a mock function with given fields: field +func (_m *PropertyFieldStore) Update(field []*model.PropertyField) ([]*model.PropertyField, error) { + ret := _m.Called(field) + + if len(ret) == 0 { + panic("no return value specified for Update") + } + + var r0 []*model.PropertyField + var r1 error + if rf, ok := ret.Get(0).(func([]*model.PropertyField) ([]*model.PropertyField, error)); ok { + return rf(field) + } + if rf, ok := ret.Get(0).(func([]*model.PropertyField) []*model.PropertyField); ok { + r0 = rf(field) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.PropertyField) + } + } + + if rf, ok := ret.Get(1).(func([]*model.PropertyField) error); ok { + r1 = rf(field) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewPropertyFieldStore creates a new instance of PropertyFieldStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewPropertyFieldStore(t interface { + mock.TestingT + Cleanup(func()) +}) *PropertyFieldStore { + mock := &PropertyFieldStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/server/channels/store/storetest/mocks/PropertyGroupStore.go b/server/channels/store/storetest/mocks/PropertyGroupStore.go new file mode 100644 index 00000000000..40692185e80 --- /dev/null +++ b/server/channels/store/storetest/mocks/PropertyGroupStore.go @@ -0,0 +1,89 @@ +// Code generated by mockery v2.42.2. DO NOT EDIT. + +// Regenerate this file using `make store-mocks`. + +package mocks + +import ( + model "github.com/mattermost/mattermost/server/public/model" + mock "github.com/stretchr/testify/mock" +) + +// PropertyGroupStore is an autogenerated mock type for the PropertyGroupStore type +type PropertyGroupStore struct { + mock.Mock +} + +// Get provides a mock function with given fields: name +func (_m *PropertyGroupStore) Get(name string) (*model.PropertyGroup, error) { + ret := _m.Called(name) + + if len(ret) == 0 { + panic("no return value specified for Get") + } + + var r0 *model.PropertyGroup + var r1 error + if rf, ok := ret.Get(0).(func(string) (*model.PropertyGroup, error)); ok { + return rf(name) + } + if rf, ok := ret.Get(0).(func(string) *model.PropertyGroup); ok { + r0 = rf(name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.PropertyGroup) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Register provides a mock function with given fields: name +func (_m *PropertyGroupStore) Register(name string) (*model.PropertyGroup, error) { + ret := _m.Called(name) + + if len(ret) == 0 { + panic("no return value specified for Register") + } + + var r0 *model.PropertyGroup + var r1 error + if rf, ok := ret.Get(0).(func(string) (*model.PropertyGroup, error)); ok { + return rf(name) + } + if rf, ok := ret.Get(0).(func(string) *model.PropertyGroup); ok { + r0 = rf(name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.PropertyGroup) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewPropertyGroupStore creates a new instance of PropertyGroupStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewPropertyGroupStore(t interface { + mock.TestingT + Cleanup(func()) +}) *PropertyGroupStore { + mock := &PropertyGroupStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/server/channels/store/storetest/mocks/PropertyValueStore.go b/server/channels/store/storetest/mocks/PropertyValueStore.go new file mode 100644 index 00000000000..d5b3827ab81 --- /dev/null +++ b/server/channels/store/storetest/mocks/PropertyValueStore.go @@ -0,0 +1,215 @@ +// Code generated by mockery v2.42.2. DO NOT EDIT. + +// Regenerate this file using `make store-mocks`. + +package mocks + +import ( + model "github.com/mattermost/mattermost/server/public/model" + mock "github.com/stretchr/testify/mock" +) + +// PropertyValueStore is an autogenerated mock type for the PropertyValueStore type +type PropertyValueStore struct { + mock.Mock +} + +// Create provides a mock function with given fields: value +func (_m *PropertyValueStore) Create(value *model.PropertyValue) (*model.PropertyValue, error) { + ret := _m.Called(value) + + if len(ret) == 0 { + panic("no return value specified for Create") + } + + var r0 *model.PropertyValue + var r1 error + if rf, ok := ret.Get(0).(func(*model.PropertyValue) (*model.PropertyValue, error)); ok { + return rf(value) + } + if rf, ok := ret.Get(0).(func(*model.PropertyValue) *model.PropertyValue); ok { + r0 = rf(value) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.PropertyValue) + } + } + + if rf, ok := ret.Get(1).(func(*model.PropertyValue) error); ok { + r1 = rf(value) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Delete provides a mock function with given fields: id +func (_m *PropertyValueStore) Delete(id string) error { + ret := _m.Called(id) + + if len(ret) == 0 { + panic("no return value specified for Delete") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DeleteForField provides a mock function with given fields: id +func (_m *PropertyValueStore) DeleteForField(id string) error { + ret := _m.Called(id) + + if len(ret) == 0 { + panic("no return value specified for DeleteForField") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Get provides a mock function with given fields: id +func (_m *PropertyValueStore) Get(id string) (*model.PropertyValue, error) { + ret := _m.Called(id) + + if len(ret) == 0 { + panic("no return value specified for Get") + } + + var r0 *model.PropertyValue + var r1 error + if rf, ok := ret.Get(0).(func(string) (*model.PropertyValue, error)); ok { + return rf(id) + } + if rf, ok := ret.Get(0).(func(string) *model.PropertyValue); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.PropertyValue) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetMany provides a mock function with given fields: ids +func (_m *PropertyValueStore) GetMany(ids []string) ([]*model.PropertyValue, error) { + ret := _m.Called(ids) + + if len(ret) == 0 { + panic("no return value specified for GetMany") + } + + var r0 []*model.PropertyValue + var r1 error + if rf, ok := ret.Get(0).(func([]string) ([]*model.PropertyValue, error)); ok { + return rf(ids) + } + if rf, ok := ret.Get(0).(func([]string) []*model.PropertyValue); ok { + r0 = rf(ids) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.PropertyValue) + } + } + + if rf, ok := ret.Get(1).(func([]string) error); ok { + r1 = rf(ids) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SearchPropertyValues provides a mock function with given fields: opts +func (_m *PropertyValueStore) SearchPropertyValues(opts model.PropertyValueSearchOpts) ([]*model.PropertyValue, error) { + ret := _m.Called(opts) + + if len(ret) == 0 { + panic("no return value specified for SearchPropertyValues") + } + + var r0 []*model.PropertyValue + var r1 error + if rf, ok := ret.Get(0).(func(model.PropertyValueSearchOpts) ([]*model.PropertyValue, error)); ok { + return rf(opts) + } + if rf, ok := ret.Get(0).(func(model.PropertyValueSearchOpts) []*model.PropertyValue); ok { + r0 = rf(opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.PropertyValue) + } + } + + if rf, ok := ret.Get(1).(func(model.PropertyValueSearchOpts) error); ok { + r1 = rf(opts) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Update provides a mock function with given fields: field +func (_m *PropertyValueStore) Update(field []*model.PropertyValue) ([]*model.PropertyValue, error) { + ret := _m.Called(field) + + if len(ret) == 0 { + panic("no return value specified for Update") + } + + var r0 []*model.PropertyValue + var r1 error + if rf, ok := ret.Get(0).(func([]*model.PropertyValue) ([]*model.PropertyValue, error)); ok { + return rf(field) + } + if rf, ok := ret.Get(0).(func([]*model.PropertyValue) []*model.PropertyValue); ok { + r0 = rf(field) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.PropertyValue) + } + } + + if rf, ok := ret.Get(1).(func([]*model.PropertyValue) error); ok { + r1 = rf(field) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewPropertyValueStore creates a new instance of PropertyValueStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewPropertyValueStore(t interface { + mock.TestingT + Cleanup(func()) +}) *PropertyValueStore { + mock := &PropertyValueStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/server/channels/store/storetest/mocks/Store.go b/server/channels/store/storetest/mocks/Store.go index a52b352c9c2..370d7e362f0 100644 --- a/server/channels/store/storetest/mocks/Store.go +++ b/server/channels/store/storetest/mocks/Store.go @@ -798,6 +798,66 @@ func (_m *Store) ProductNotices() store.ProductNoticesStore { return r0 } +// PropertyField provides a mock function with given fields: +func (_m *Store) PropertyField() store.PropertyFieldStore { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for PropertyField") + } + + var r0 store.PropertyFieldStore + if rf, ok := ret.Get(0).(func() store.PropertyFieldStore); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.PropertyFieldStore) + } + } + + return r0 +} + +// PropertyGroup provides a mock function with given fields: +func (_m *Store) PropertyGroup() store.PropertyGroupStore { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for PropertyGroup") + } + + var r0 store.PropertyGroupStore + if rf, ok := ret.Get(0).(func() store.PropertyGroupStore); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.PropertyGroupStore) + } + } + + return r0 +} + +// PropertyValue provides a mock function with given fields: +func (_m *Store) PropertyValue() store.PropertyValueStore { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for PropertyValue") + } + + var r0 store.PropertyValueStore + if rf, ok := ret.Get(0).(func() store.PropertyValueStore); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.PropertyValueStore) + } + } + + return r0 +} + // Reaction provides a mock function with given fields: func (_m *Store) Reaction() store.ReactionStore { ret := _m.Called() diff --git a/server/channels/store/storetest/property_field_store.go b/server/channels/store/storetest/property_field_store.go new file mode 100644 index 00000000000..a2880c21424 --- /dev/null +++ b/server/channels/store/storetest/property_field_store.go @@ -0,0 +1,461 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package storetest + +import ( + "database/sql" + "testing" + "time" + + "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/require" +) + +func TestPropertyFieldStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) { + t.Run("CreatePropertyField", func(t *testing.T) { testCreatePropertyField(t, rctx, ss) }) + t.Run("GetPropertyField", func(t *testing.T) { testGetPropertyField(t, rctx, ss) }) + t.Run("GetManyPropertyFields", func(t *testing.T) { testGetManyPropertyFields(t, rctx, ss) }) + t.Run("UpdatePropertyField", func(t *testing.T) { testUpdatePropertyField(t, rctx, ss) }) + t.Run("DeletePropertyField", func(t *testing.T) { testDeletePropertyField(t, rctx, ss) }) + t.Run("SearchPropertyFields", func(t *testing.T) { testSearchPropertyFields(t, rctx, ss) }) +} + +func testCreatePropertyField(t *testing.T, _ request.CTX, ss store.Store) { + t.Run("should fail if the property field already has an ID set", func(t *testing.T) { + newField := &model.PropertyField{ID: "sampleid"} + field, err := ss.PropertyField().Create(newField) + require.Zero(t, field) + var eii *store.ErrInvalidInput + require.ErrorAs(t, err, &eii) + }) + + t.Run("should fail if the property field is not valid", func(t *testing.T) { + newField := &model.PropertyField{GroupID: ""} + field, err := ss.PropertyField().Create(newField) + require.Zero(t, field) + require.ErrorContains(t, err, "model.property_field.is_valid.app_error") + + newField = &model.PropertyField{GroupID: model.NewId(), Name: ""} + field, err = ss.PropertyField().Create(newField) + require.Zero(t, field) + require.ErrorContains(t, err, "model.property_field.is_valid.app_error") + }) + + newField := &model.PropertyField{ + GroupID: model.NewId(), + Name: "My new property field", + Type: model.PropertyFieldTypeText, + Attrs: map[string]any{ + "locked": true, + "special": "value", + }, + } + + t.Run("should be able to create a property field", func(t *testing.T) { + field, err := ss.PropertyField().Create(newField) + require.NoError(t, err) + require.NotZero(t, field.ID) + require.NotZero(t, field.CreateAt) + require.NotZero(t, field.UpdateAt) + require.Zero(t, field.DeleteAt) + }) + + t.Run("should enforce the field's uniqueness", func(t *testing.T) { + newField.ID = "" + field, err := ss.PropertyField().Create(newField) + require.Error(t, err) + require.Empty(t, field) + }) +} + +func testGetPropertyField(t *testing.T, _ request.CTX, ss store.Store) { + t.Run("should fail on nonexisting field", func(t *testing.T) { + field, err := ss.PropertyField().Get(model.NewId()) + require.Zero(t, field) + require.ErrorIs(t, err, sql.ErrNoRows) + }) + + t.Run("should be able to retrieve an existing property field", func(t *testing.T) { + newField := &model.PropertyField{ + GroupID: model.NewId(), + Name: "My new property field", + Type: model.PropertyFieldTypeText, + Attrs: map[string]any{ + "locked": true, + "special": "value", + }, + } + _, err := ss.PropertyField().Create(newField) + require.NoError(t, err) + require.NotZero(t, newField.ID) + + field, err := ss.PropertyField().Get(newField.ID) + require.NoError(t, err) + require.Equal(t, newField.ID, field.ID) + require.True(t, field.Attrs["locked"].(bool)) + require.Equal(t, "value", field.Attrs["special"]) + }) +} + +func testGetManyPropertyFields(t *testing.T, _ request.CTX, ss store.Store) { + t.Run("should fail on nonexisting fields", func(t *testing.T) { + fields, err := ss.PropertyField().GetMany([]string{model.NewId(), model.NewId()}) + require.Empty(t, fields) + require.ErrorContains(t, err, "missmatch results") + }) + + newFields := []*model.PropertyField{} + for _, fieldName := range []string{"field1", "field2", "field3"} { + newField := &model.PropertyField{ + GroupID: model.NewId(), + Name: fieldName, + Type: model.PropertyFieldTypeText, + } + _, err := ss.PropertyField().Create(newField) + require.NoError(t, err) + require.NotZero(t, newField.ID) + + newFields = append(newFields, newField) + } + + t.Run("should fail if at least one of the ids is nonexistent", func(t *testing.T) { + fields, err := ss.PropertyField().GetMany([]string{newFields[0].ID, newFields[1].ID, model.NewId()}) + require.Empty(t, fields) + require.ErrorContains(t, err, "missmatch results") + }) + + t.Run("should be able to retrieve existing property fields", func(t *testing.T) { + fields, err := ss.PropertyField().GetMany([]string{newFields[0].ID, newFields[1].ID, newFields[2].ID}) + require.NoError(t, err) + require.Len(t, fields, 3) + require.ElementsMatch(t, newFields, fields) + }) +} + +func testUpdatePropertyField(t *testing.T, _ request.CTX, ss store.Store) { + t.Run("should fail on nonexisting field", func(t *testing.T) { + field := &model.PropertyField{ + ID: model.NewId(), + GroupID: model.NewId(), + Name: "My property field", + Type: model.PropertyFieldTypeText, + CreateAt: model.GetMillis(), + } + updatedField, err := ss.PropertyField().Update([]*model.PropertyField{field}) + require.Zero(t, updatedField) + var enf *store.ErrNotFound + require.ErrorAs(t, err, &enf) + }) + + t.Run("should fail if the property field is not valid", func(t *testing.T) { + field := &model.PropertyField{ + GroupID: model.NewId(), + Name: "My property field", + Type: model.PropertyFieldTypeText, + } + _, err := ss.PropertyField().Create(field) + require.NoError(t, err) + require.NotZero(t, field.ID) + + field.GroupID = "" + updatedField, err := ss.PropertyField().Update([]*model.PropertyField{field}) + require.Zero(t, updatedField) + require.ErrorContains(t, err, "model.property_field.is_valid.app_error") + + field.GroupID = model.NewId() + field.Name = "" + updatedField, err = ss.PropertyField().Update([]*model.PropertyField{field}) + require.Zero(t, updatedField) + require.ErrorContains(t, err, "model.property_field.is_valid.app_error") + }) + + t.Run("should be able to update multiple property fields", func(t *testing.T) { + field1 := &model.PropertyField{ + GroupID: model.NewId(), + Name: "First field", + Type: model.PropertyFieldTypeText, + Attrs: map[string]any{ + "locked": true, + "special": "value", + }, + } + + field2 := &model.PropertyField{ + GroupID: model.NewId(), + Name: "Second field", + Type: model.PropertyFieldTypeSelect, + Attrs: map[string]any{ + "options": []string{"a", "b"}, + }, + } + + for _, field := range []*model.PropertyField{field1, field2} { + _, err := ss.PropertyField().Create(field) + require.NoError(t, err) + require.NotZero(t, field.ID) + } + time.Sleep(10 * time.Millisecond) + + field1.Name = "Updated first" + field1.Type = model.PropertyFieldTypeSelect + field1.Attrs = map[string]any{ + "locked": false, + "new_field": "new_value", + } + + field2.Name = "Updated second" + field2.Attrs = map[string]any{ + "options": []string{"x", "y", "z"}, + } + + _, err := ss.PropertyField().Update([]*model.PropertyField{field1, field2}) + require.NoError(t, err) + + // Verify first field + updated1, err := ss.PropertyField().Get(field1.ID) + require.NoError(t, err) + require.Equal(t, "Updated first", updated1.Name) + require.Equal(t, model.PropertyFieldTypeSelect, updated1.Type) + require.False(t, updated1.Attrs["locked"].(bool)) + require.NotContains(t, updated1.Attrs, "special") + require.Equal(t, "new_value", updated1.Attrs["new_field"]) + require.Greater(t, updated1.UpdateAt, updated1.CreateAt) + + // Verify second field + updated2, err := ss.PropertyField().Get(field2.ID) + require.NoError(t, err) + require.Equal(t, "Updated second", updated2.Name) + require.Equal(t, model.PropertyFieldTypeSelect, updated2.Type) + require.ElementsMatch(t, []string{"x", "y", "z"}, updated2.Attrs["options"]) + require.Greater(t, updated2.UpdateAt, updated2.CreateAt) + }) + + t.Run("should not update any fields if one update is invalid", func(t *testing.T) { + // Create two valid fields + groupID := model.NewId() + field1 := &model.PropertyField{ + GroupID: groupID, + Name: "Field 1", + Type: model.PropertyFieldTypeText, + Attrs: map[string]any{ + "key": "value", + }, + } + + field2 := &model.PropertyField{ + GroupID: groupID, + Name: "Field 2", + Type: model.PropertyFieldTypeText, + Attrs: map[string]any{ + "key": "value", + }, + } + + for _, field := range []*model.PropertyField{field1, field2} { + _, err := ss.PropertyField().Create(field) + require.NoError(t, err) + } + + originalUpdateAt1 := field1.UpdateAt + originalUpdateAt2 := field2.UpdateAt + + // Try to update both fields, but make one invalid + field1.Name = "Valid update" + field2.GroupID = "Invalid ID" + + _, err := ss.PropertyField().Update([]*model.PropertyField{field1, field2}) + require.ErrorContains(t, err, "model.property_field.is_valid.app_error") + + // Check that fields were not updated + updated1, err := ss.PropertyField().Get(field1.ID) + require.NoError(t, err) + require.Equal(t, "Field 1", updated1.Name) + require.Equal(t, originalUpdateAt1, updated1.UpdateAt) + + updated2, err := ss.PropertyField().Get(field2.ID) + require.NoError(t, err) + require.Equal(t, groupID, updated2.GroupID) + require.Equal(t, originalUpdateAt2, updated2.UpdateAt) + }) +} + +func testDeletePropertyField(t *testing.T, _ request.CTX, ss store.Store) { + t.Run("should fail on nonexisting field", func(t *testing.T) { + err := ss.PropertyField().Delete(model.NewId()) + var enf *store.ErrNotFound + require.ErrorAs(t, err, &enf) + }) + + newField := &model.PropertyField{ + GroupID: model.NewId(), + Name: "My property field", + Type: model.PropertyFieldTypeText, + } + + t.Run("should be able to delete an existing property field", func(t *testing.T) { + field, err := ss.PropertyField().Create(newField) + require.NoError(t, err) + require.NotEmpty(t, field.ID) + + err = ss.PropertyField().Delete(field.ID) + require.NoError(t, err) + + // Verify the field was soft-deleted + deletedField, err := ss.PropertyField().Get(field.ID) + require.NoError(t, err) + require.NotZero(t, deletedField.DeleteAt) + }) + + t.Run("should be able to create a new field with the same details as the deleted one", func(t *testing.T) { + newField.ID = "" + field, err := ss.PropertyField().Create(newField) + require.NoError(t, err) + require.NotEmpty(t, field.ID) + }) +} + +func testSearchPropertyFields(t *testing.T, _ request.CTX, ss store.Store) { + groupID := model.NewId() + targetID := model.NewId() + + // Define test property fields + field1 := &model.PropertyField{ + GroupID: groupID, + Name: "Field 1", + Type: model.PropertyFieldTypeText, + TargetID: targetID, + TargetType: "test_type", + } + + field2 := &model.PropertyField{ + GroupID: groupID, + Name: "Field 2", + Type: model.PropertyFieldTypeSelect, + TargetID: targetID, + TargetType: "other_type", + } + + field3 := &model.PropertyField{ + GroupID: model.NewId(), + Name: "Field 3", + Type: model.PropertyFieldTypeText, + TargetType: "test_type", + } + + field4 := &model.PropertyField{ + GroupID: groupID, + Name: "Field 4", + Type: model.PropertyFieldTypeText, + TargetType: "test_type", + } + + for _, field := range []*model.PropertyField{field1, field2, field3, field4} { + _, err := ss.PropertyField().Create(field) + require.NoError(t, err) + time.Sleep(10 * time.Millisecond) + } + + // Delete one field for deletion tests + require.NoError(t, ss.PropertyField().Delete(field4.ID)) + + tests := []struct { + name string + opts model.PropertyFieldSearchOpts + expectedError bool + expectedIDs []string + }{ + { + name: "negative page", + opts: model.PropertyFieldSearchOpts{ + Page: -1, + PerPage: 10, + }, + expectedError: true, + }, + { + name: "negative per_page", + opts: model.PropertyFieldSearchOpts{ + Page: 0, + PerPage: -1, + }, + expectedError: true, + }, + { + name: "filter by group_id", + opts: model.PropertyFieldSearchOpts{ + GroupID: groupID, + Page: 0, + PerPage: 10, + }, + expectedIDs: []string{field1.ID, field2.ID}, + }, + { + name: "filter by group_id including deleted", + opts: model.PropertyFieldSearchOpts{ + GroupID: groupID, + Page: 0, + PerPage: 10, + IncludeDeleted: true, + }, + expectedIDs: []string{field1.ID, field2.ID, field4.ID}, + }, + { + name: "filter by target_type", + opts: model.PropertyFieldSearchOpts{ + TargetType: "test_type", + Page: 0, + PerPage: 10, + }, + expectedIDs: []string{field1.ID, field3.ID}, + }, + { + name: "filter by target_id", + opts: model.PropertyFieldSearchOpts{ + TargetID: targetID, + Page: 0, + PerPage: 10, + }, + expectedIDs: []string{field1.ID, field2.ID}, + }, + { + name: "pagination page 0", + opts: model.PropertyFieldSearchOpts{ + GroupID: groupID, + Page: 0, + PerPage: 2, + IncludeDeleted: true, + }, + expectedIDs: []string{field1.ID, field2.ID}, + }, + { + name: "pagination page 1", + opts: model.PropertyFieldSearchOpts{ + GroupID: groupID, + Page: 1, + PerPage: 2, + IncludeDeleted: true, + }, + expectedIDs: []string{field4.ID}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + results, err := ss.PropertyField().SearchPropertyFields(tc.opts) + if tc.expectedError { + require.Error(t, err) + return + } + + require.NoError(t, err) + var ids = make([]string, len(results)) + for i, field := range results { + ids[i] = field.ID + } + require.ElementsMatch(t, tc.expectedIDs, ids) + }) + } +} diff --git a/server/channels/store/storetest/property_group_store.go b/server/channels/store/storetest/property_group_store.go new file mode 100644 index 00000000000..527f8d8513e --- /dev/null +++ b/server/channels/store/storetest/property_group_store.go @@ -0,0 +1,36 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package storetest + +import ( + "testing" + + "github.com/mattermost/mattermost/server/public/shared/request" + "github.com/mattermost/mattermost/server/v8/channels/store" + "github.com/stretchr/testify/require" +) + +func TestPropertyGroupStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) { + t.Run("RegisterAndGetPropertyGroup", func(t *testing.T) { testRegisterAndGetPropertyGroup(t, rctx, ss) }) +} + +func testRegisterAndGetPropertyGroup(t *testing.T, _ request.CTX, ss store.Store) { + groupName := "samplename" + var id string + + t.Run("should be able to register a new group", func(t *testing.T) { + group, err := ss.PropertyGroup().Register(groupName) + require.NoError(t, err) + require.NotZero(t, group.ID) + require.Equal(t, groupName, group.Name) + id = group.ID + }) + + t.Run("should be able to retrieve an existing group", func(t *testing.T) { + group, err := ss.PropertyGroup().Register(groupName) + require.NoError(t, err) + require.Equal(t, groupName, group.Name) + require.Equal(t, id, group.ID) + }) +} diff --git a/server/channels/store/storetest/property_value_store.go b/server/channels/store/storetest/property_value_store.go new file mode 100644 index 00000000000..c43fd6810e0 --- /dev/null +++ b/server/channels/store/storetest/property_value_store.go @@ -0,0 +1,535 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package storetest + +import ( + "database/sql" + "fmt" + "testing" + "time" + + "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/require" +) + +func TestPropertyValueStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) { + t.Run("CreatePropertyValue", func(t *testing.T) { testCreatePropertyValue(t, rctx, ss) }) + t.Run("GetPropertyValue", func(t *testing.T) { testGetPropertyValue(t, rctx, ss) }) + t.Run("GetManyPropertyValues", func(t *testing.T) { testGetManyPropertyValues(t, rctx, ss) }) + t.Run("UpdatePropertyValue", func(t *testing.T) { testUpdatePropertyValue(t, rctx, ss) }) + t.Run("DeletePropertyValue", func(t *testing.T) { testDeletePropertyValue(t, rctx, ss) }) + t.Run("SearchPropertyValues", func(t *testing.T) { testSearchPropertyValues(t, rctx, ss) }) + t.Run("DeleteForField", func(t *testing.T) { testDeleteForField(t, rctx, ss) }) +} + +func testCreatePropertyValue(t *testing.T, _ request.CTX, ss store.Store) { + t.Run("should fail if the property value already has an ID set", func(t *testing.T) { + newValue := &model.PropertyValue{ID: "sampleid"} + value, err := ss.PropertyValue().Create(newValue) + require.Zero(t, value) + var eii *store.ErrInvalidInput + require.ErrorAs(t, err, &eii) + }) + + t.Run("should fail if the property value is not valid", func(t *testing.T) { + newValue := &model.PropertyValue{TargetID: ""} + value, err := ss.PropertyValue().Create(newValue) + require.Zero(t, value) + require.ErrorContains(t, err, "model.property_value.is_valid.app_error") + + newValue = &model.PropertyValue{TargetID: model.NewId(), TargetType: ""} + value, err = ss.PropertyValue().Create(newValue) + require.Zero(t, value) + require.ErrorContains(t, err, "model.property_value.is_valid.app_error") + }) + + newValue := &model.PropertyValue{ + TargetID: model.NewId(), + TargetType: "test_type", + GroupID: model.NewId(), + FieldID: model.NewId(), + Value: "test value", + } + + t.Run("should be able to create a property value", func(t *testing.T) { + value, err := ss.PropertyValue().Create(newValue) + require.NoError(t, err) + require.NotZero(t, value.ID) + require.NotZero(t, value.CreateAt) + require.NotZero(t, value.UpdateAt) + require.Zero(t, value.DeleteAt) + }) + + t.Run("should enforce the value's uniqueness", func(t *testing.T) { + newValue.ID = "" + value, err := ss.PropertyValue().Create(newValue) + require.Error(t, err) + require.Zero(t, value) + }) +} + +func testGetPropertyValue(t *testing.T, _ request.CTX, ss store.Store) { + t.Run("should fail on nonexisting value", func(t *testing.T) { + value, err := ss.PropertyValue().Get(model.NewId()) + require.Zero(t, value) + require.ErrorIs(t, err, sql.ErrNoRows) + }) + + t.Run("should be able to retrieve an existing property value", func(t *testing.T) { + newValue := &model.PropertyValue{ + TargetID: model.NewId(), + TargetType: "test_type", + GroupID: model.NewId(), + FieldID: model.NewId(), + Value: "test value", + } + _, err := ss.PropertyValue().Create(newValue) + require.NoError(t, err) + require.NotZero(t, newValue.ID) + + value, err := ss.PropertyValue().Get(newValue.ID) + require.NoError(t, err) + require.Equal(t, newValue.ID, value.ID) + require.Equal(t, newValue.Value, value.Value) + }) +} + +func testGetManyPropertyValues(t *testing.T, _ request.CTX, ss store.Store) { + t.Run("should fail on nonexisting values", func(t *testing.T) { + values, err := ss.PropertyValue().GetMany([]string{model.NewId(), model.NewId()}) + require.Empty(t, values) + require.ErrorContains(t, err, "missmatch results") + }) + + newValues := []*model.PropertyValue{} + for i := 0; i < 3; i++ { + newValue := &model.PropertyValue{ + TargetID: model.NewId(), + TargetType: "test_type", + GroupID: model.NewId(), + FieldID: model.NewId(), + Value: fmt.Sprintf("test value %d", i), + } + _, err := ss.PropertyValue().Create(newValue) + require.NoError(t, err) + require.NotZero(t, newValue.ID) + + newValues = append(newValues, newValue) + } + + t.Run("should fail if at least one of the ids is nonexistent", func(t *testing.T) { + values, err := ss.PropertyValue().GetMany([]string{newValues[0].ID, newValues[1].ID, model.NewId()}) + require.Empty(t, values) + require.ErrorContains(t, err, "missmatch results") + }) + + t.Run("should be able to retrieve existing property values", func(t *testing.T) { + values, err := ss.PropertyValue().GetMany([]string{newValues[0].ID, newValues[1].ID, newValues[2].ID}) + require.NoError(t, err) + require.Len(t, values, 3) + require.ElementsMatch(t, newValues, values) + }) +} + +func testUpdatePropertyValue(t *testing.T, _ request.CTX, ss store.Store) { + t.Run("should fail on nonexisting value", func(t *testing.T) { + value := &model.PropertyValue{ + ID: model.NewId(), + TargetID: model.NewId(), + TargetType: "test_type", + GroupID: model.NewId(), + FieldID: model.NewId(), + Value: "test value", + CreateAt: model.GetMillis(), + } + updatedValue, err := ss.PropertyValue().Update([]*model.PropertyValue{value}) + require.Zero(t, updatedValue) + var enf *store.ErrNotFound + require.ErrorAs(t, err, &enf) + }) + + t.Run("should fail if the property value is not valid", func(t *testing.T) { + value := &model.PropertyValue{ + TargetID: model.NewId(), + TargetType: "test_type", + GroupID: model.NewId(), + FieldID: model.NewId(), + Value: "test value", + } + _, err := ss.PropertyValue().Create(value) + require.NoError(t, err) + require.NotZero(t, value.ID) + + value.TargetID = "" + updatedValue, err := ss.PropertyValue().Update([]*model.PropertyValue{value}) + require.Zero(t, updatedValue) + require.ErrorContains(t, err, "model.property_value.is_valid.app_error") + + value.TargetID = model.NewId() + value.GroupID = "" + updatedValue, err = ss.PropertyValue().Update([]*model.PropertyValue{value}) + require.Zero(t, updatedValue) + require.ErrorContains(t, err, "model.property_value.is_valid.app_error") + }) + + t.Run("should be able to update multiple property values", func(t *testing.T) { + value1 := &model.PropertyValue{ + TargetID: model.NewId(), + TargetType: "test_type", + GroupID: model.NewId(), + FieldID: model.NewId(), + Value: "value 1", + } + + value2 := &model.PropertyValue{ + TargetID: model.NewId(), + TargetType: "test_type", + GroupID: model.NewId(), + FieldID: model.NewId(), + Value: "value 2", + } + + for _, value := range []*model.PropertyValue{value1, value2} { + _, err := ss.PropertyValue().Create(value) + require.NoError(t, err) + require.NotZero(t, value.ID) + } + time.Sleep(10 * time.Millisecond) + + value1.Value = "updated value 1" + value2.Value = "updated value 2" + + _, err := ss.PropertyValue().Update([]*model.PropertyValue{value1, value2}) + require.NoError(t, err) + + // Verify first value + updated1, err := ss.PropertyValue().Get(value1.ID) + require.NoError(t, err) + require.Equal(t, "updated value 1", updated1.Value) + require.Greater(t, updated1.UpdateAt, updated1.CreateAt) + + // Verify second value + updated2, err := ss.PropertyValue().Get(value2.ID) + require.NoError(t, err) + require.Equal(t, "updated value 2", updated2.Value) + require.Greater(t, updated2.UpdateAt, updated2.CreateAt) + }) + + t.Run("should not update any fields if one update is invalid", func(t *testing.T) { + // Create two valid values + groupID := model.NewId() + value1 := &model.PropertyValue{ + TargetID: model.NewId(), + TargetType: "test_type", + GroupID: groupID, + FieldID: model.NewId(), + Value: "Value 1", + } + + value2 := &model.PropertyValue{ + TargetID: model.NewId(), + TargetType: "test_type", + GroupID: groupID, + FieldID: model.NewId(), + Value: "Value 2", + } + + for _, value := range []*model.PropertyValue{value1, value2} { + _, err := ss.PropertyValue().Create(value) + require.NoError(t, err) + } + + originalUpdateAt1 := value1.UpdateAt + originalUpdateAt2 := value2.UpdateAt + + // Try to update both value, but make one invalid + value1.Value = "Valid update" + value2.GroupID = "Invalid ID" + + _, err := ss.PropertyValue().Update([]*model.PropertyValue{value1, value2}) + require.Error(t, err) + require.Contains(t, err.Error(), "model.property_value.is_valid.app_error") + + // Check that values were not updated + updated1, err := ss.PropertyValue().Get(value1.ID) + require.NoError(t, err) + require.Equal(t, "Value 1", updated1.Value) + require.Equal(t, originalUpdateAt1, updated1.UpdateAt) + + updated2, err := ss.PropertyValue().Get(value2.ID) + require.NoError(t, err) + require.Equal(t, groupID, updated2.GroupID) + require.Equal(t, originalUpdateAt2, updated2.UpdateAt) + }) +} + +func testDeletePropertyValue(t *testing.T, _ request.CTX, ss store.Store) { + t.Run("should fail on nonexisting value", func(t *testing.T) { + err := ss.PropertyValue().Delete(model.NewId()) + var enf *store.ErrNotFound + require.ErrorAs(t, err, &enf) + }) + + t.Run("should be able to delete an existing property value", func(t *testing.T) { + newValue := &model.PropertyValue{ + TargetID: model.NewId(), + TargetType: "test_type", + GroupID: model.NewId(), + FieldID: model.NewId(), + Value: "test value", + } + value, err := ss.PropertyValue().Create(newValue) + require.NoError(t, err) + require.NotEmpty(t, value.ID) + + err = ss.PropertyValue().Delete(value.ID) + require.NoError(t, err) + + // Verify the value was soft-deleted + deletedValue, err := ss.PropertyValue().Get(value.ID) + require.NoError(t, err) + require.NotZero(t, deletedValue.DeleteAt) + }) + + t.Run("should be able to create a new value with the same details as the deleted one", func(t *testing.T) { + sameDetailsValue := &model.PropertyValue{ + TargetID: model.NewId(), + TargetType: "test_type", + GroupID: model.NewId(), + FieldID: model.NewId(), + Value: "test value", + } + value, err := ss.PropertyValue().Create(sameDetailsValue) + require.NoError(t, err) + require.NotEmpty(t, value.ID) + require.Equal(t, sameDetailsValue.Value, value.Value) + }) +} + +func testSearchPropertyValues(t *testing.T, _ request.CTX, ss store.Store) { + groupID := model.NewId() + targetID := model.NewId() + fieldID := model.NewId() + + // Define test property values + value1 := &model.PropertyValue{ + GroupID: groupID, + TargetID: targetID, + TargetType: "test_type", + FieldID: fieldID, + Value: "value 1", + } + + value2 := &model.PropertyValue{ + GroupID: groupID, + TargetID: targetID, + TargetType: "other_type", + FieldID: model.NewId(), + Value: "value 2", + } + + value3 := &model.PropertyValue{ + GroupID: model.NewId(), + TargetID: model.NewId(), + TargetType: "test_type", + FieldID: model.NewId(), + Value: "value 3", + } + + value4 := &model.PropertyValue{ + GroupID: groupID, + TargetID: model.NewId(), + TargetType: "test_type", + FieldID: fieldID, + Value: "value 4", + } + + for _, value := range []*model.PropertyValue{value1, value2, value3, value4} { + _, err := ss.PropertyValue().Create(value) + require.NoError(t, err) + time.Sleep(10 * time.Millisecond) + } + + // Delete one value for deletion tests + require.NoError(t, ss.PropertyValue().Delete(value4.ID)) + + tests := []struct { + name string + opts model.PropertyValueSearchOpts + expectedError bool + expectedIDs []string + }{ + { + name: "negative page", + opts: model.PropertyValueSearchOpts{ + Page: -1, + PerPage: 10, + }, + expectedError: true, + }, + { + name: "negative per_page", + opts: model.PropertyValueSearchOpts{ + Page: 0, + PerPage: -1, + }, + expectedError: true, + }, + { + name: "filter by group_id", + opts: model.PropertyValueSearchOpts{ + GroupID: groupID, + Page: 0, + PerPage: 10, + }, + expectedIDs: []string{value1.ID, value2.ID}, + }, + { + name: "filter by group_id and target_type", + opts: model.PropertyValueSearchOpts{ + GroupID: groupID, + TargetType: "test_type", + Page: 0, + PerPage: 10, + }, + expectedIDs: []string{value1.ID}, + }, + { + name: "filter by group_id and target_type including deleted", + opts: model.PropertyValueSearchOpts{ + GroupID: groupID, + TargetType: "test_type", + IncludeDeleted: true, + Page: 0, + PerPage: 10, + }, + expectedIDs: []string{value1.ID, value4.ID}, + }, + { + name: "filter by target_id", + opts: model.PropertyValueSearchOpts{ + TargetID: targetID, + Page: 0, + PerPage: 10, + }, + expectedIDs: []string{value1.ID, value2.ID}, + }, + { + name: "filter by group_id and target_id", + opts: model.PropertyValueSearchOpts{ + GroupID: groupID, + TargetID: targetID, + Page: 0, + PerPage: 10, + }, + expectedIDs: []string{value1.ID, value2.ID}, + }, + { + name: "filter by field_id", + opts: model.PropertyValueSearchOpts{ + FieldID: fieldID, + Page: 0, + PerPage: 10, + }, + expectedIDs: []string{value1.ID}, + }, + { + name: "filter by field_id including deleted", + opts: model.PropertyValueSearchOpts{ + FieldID: fieldID, + IncludeDeleted: true, + Page: 0, + PerPage: 10, + }, + expectedIDs: []string{value1.ID, value4.ID}, + }, + { + name: "pagination page 0", + opts: model.PropertyValueSearchOpts{ + GroupID: groupID, + Page: 0, + PerPage: 1, + }, + expectedIDs: []string{value1.ID}, + }, + { + name: "pagination page 1", + opts: model.PropertyValueSearchOpts{ + GroupID: groupID, + Page: 1, + PerPage: 1, + }, + expectedIDs: []string{value2.ID}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + results, err := ss.PropertyValue().SearchPropertyValues(tc.opts) + if tc.expectedError { + require.Error(t, err) + return + } + + require.NoError(t, err) + var ids = make([]string, len(results)) + for i, value := range results { + ids[i] = value.ID + } + require.ElementsMatch(t, tc.expectedIDs, ids) + }) + } +} + +func testDeleteForField(t *testing.T, _ request.CTX, ss store.Store) { + fieldID := model.NewId() + + // Create test values + value1 := &model.PropertyValue{ + TargetID: model.NewId(), + TargetType: "test_type", + GroupID: model.NewId(), + FieldID: fieldID, + Value: "value 1", + } + + value2 := &model.PropertyValue{ + TargetID: model.NewId(), + TargetType: "test_type", + GroupID: model.NewId(), + FieldID: fieldID, + Value: "value 2", + } + + value3 := &model.PropertyValue{ + TargetID: model.NewId(), + TargetType: "test_type", + GroupID: model.NewId(), + FieldID: model.NewId(), // Different field ID + Value: "value 3", + } + + for _, value := range []*model.PropertyValue{value1, value2, value3} { + _, err := ss.PropertyValue().Create(value) + require.NoError(t, err) + } + + // Delete values for the field + err := ss.PropertyValue().DeleteForField(fieldID) + require.NoError(t, err) + + // Verify values were soft-deleted + deletedValues, err := ss.PropertyValue().GetMany([]string{value1.ID, value2.ID}) + require.NoError(t, err) + require.Len(t, deletedValues, 2) + require.NotZero(t, deletedValues[0].DeleteAt) + require.NotZero(t, deletedValues[1].DeleteAt) + + // Verify value with different field ID was not deleted + nonDeletedValue, err := ss.PropertyValue().Get(value3.ID) + require.NoError(t, err) + require.Zero(t, nonDeletedValue.DeleteAt) +} diff --git a/server/channels/store/storetest/store.go b/server/channels/store/storetest/store.go index 812b5601ac8..ca744d34acc 100644 --- a/server/channels/store/storetest/store.go +++ b/server/channels/store/storetest/store.go @@ -66,6 +66,9 @@ type Store struct { DesktopTokensStore mocks.DesktopTokensStore ChannelBookmarkStore mocks.ChannelBookmarkStore ScheduledPostStore mocks.ScheduledPostStore + PropertyGroupStore mocks.PropertyGroupStore + PropertyFieldStore mocks.PropertyFieldStore + PropertyValueStore mocks.PropertyValueStore } func (s *Store) SetContext(context context.Context) { s.context = context } @@ -119,6 +122,9 @@ func (s *Store) LinkMetadata() store.LinkMetadataStore { return &s.LinkMet func (s *Store) SharedChannel() store.SharedChannelStore { return &s.SharedChannelStore } func (s *Store) PostPriority() store.PostPriorityStore { return &s.PostPriorityStore } func (s *Store) ScheduledPost() store.ScheduledPostStore { return &s.ScheduledPostStore } +func (s *Store) PropertyGroup() store.PropertyGroupStore { return &s.PropertyGroupStore } +func (s *Store) PropertyField() store.PropertyFieldStore { return &s.PropertyFieldStore } +func (s *Store) PropertyValue() store.PropertyValueStore { return &s.PropertyValueStore } func (s *Store) PostAcknowledgement() store.PostAcknowledgementStore { return &s.PostAcknowledgementStore } diff --git a/server/channels/store/timerlayer/timerlayer.go b/server/channels/store/timerlayer/timerlayer.go index 22ee23e3860..4e564e2a324 100644 --- a/server/channels/store/timerlayer/timerlayer.go +++ b/server/channels/store/timerlayer/timerlayer.go @@ -46,6 +46,9 @@ type TimerLayer struct { PostPriorityStore store.PostPriorityStore PreferenceStore store.PreferenceStore ProductNoticesStore store.ProductNoticesStore + PropertyFieldStore store.PropertyFieldStore + PropertyGroupStore store.PropertyGroupStore + PropertyValueStore store.PropertyValueStore ReactionStore store.ReactionStore RemoteClusterStore store.RemoteClusterStore RetentionPolicyStore store.RetentionPolicyStore @@ -175,6 +178,18 @@ func (s *TimerLayer) ProductNotices() store.ProductNoticesStore { return s.ProductNoticesStore } +func (s *TimerLayer) PropertyField() store.PropertyFieldStore { + return s.PropertyFieldStore +} + +func (s *TimerLayer) PropertyGroup() store.PropertyGroupStore { + return s.PropertyGroupStore +} + +func (s *TimerLayer) PropertyValue() store.PropertyValueStore { + return s.PropertyValueStore +} + func (s *TimerLayer) Reaction() store.ReactionStore { return s.ReactionStore } @@ -386,6 +401,21 @@ type TimerLayerProductNoticesStore struct { Root *TimerLayer } +type TimerLayerPropertyFieldStore struct { + store.PropertyFieldStore + Root *TimerLayer +} + +type TimerLayerPropertyGroupStore struct { + store.PropertyGroupStore + Root *TimerLayer +} + +type TimerLayerPropertyValueStore struct { + store.PropertyValueStore + Root *TimerLayer +} + type TimerLayerReactionStore struct { store.ReactionStore Root *TimerLayer @@ -6979,6 +7009,246 @@ func (s *TimerLayerProductNoticesStore) View(userID string, notices []string) er return err } +func (s *TimerLayerPropertyFieldStore) Create(field *model.PropertyField) (*model.PropertyField, error) { + start := time.Now() + + result, err := s.PropertyFieldStore.Create(field) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PropertyFieldStore.Create", success, elapsed) + } + return result, err +} + +func (s *TimerLayerPropertyFieldStore) Delete(id string) error { + start := time.Now() + + err := s.PropertyFieldStore.Delete(id) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PropertyFieldStore.Delete", success, elapsed) + } + return err +} + +func (s *TimerLayerPropertyFieldStore) Get(id string) (*model.PropertyField, error) { + start := time.Now() + + result, err := s.PropertyFieldStore.Get(id) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PropertyFieldStore.Get", success, elapsed) + } + return result, err +} + +func (s *TimerLayerPropertyFieldStore) GetMany(ids []string) ([]*model.PropertyField, error) { + start := time.Now() + + result, err := s.PropertyFieldStore.GetMany(ids) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PropertyFieldStore.GetMany", success, elapsed) + } + return result, err +} + +func (s *TimerLayerPropertyFieldStore) SearchPropertyFields(opts model.PropertyFieldSearchOpts) ([]*model.PropertyField, error) { + start := time.Now() + + result, err := s.PropertyFieldStore.SearchPropertyFields(opts) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PropertyFieldStore.SearchPropertyFields", success, elapsed) + } + return result, err +} + +func (s *TimerLayerPropertyFieldStore) Update(field []*model.PropertyField) ([]*model.PropertyField, error) { + start := time.Now() + + result, err := s.PropertyFieldStore.Update(field) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PropertyFieldStore.Update", success, elapsed) + } + return result, err +} + +func (s *TimerLayerPropertyGroupStore) Get(name string) (*model.PropertyGroup, error) { + start := time.Now() + + result, err := s.PropertyGroupStore.Get(name) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PropertyGroupStore.Get", success, elapsed) + } + return result, err +} + +func (s *TimerLayerPropertyGroupStore) Register(name string) (*model.PropertyGroup, error) { + start := time.Now() + + result, err := s.PropertyGroupStore.Register(name) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PropertyGroupStore.Register", success, elapsed) + } + return result, err +} + +func (s *TimerLayerPropertyValueStore) Create(value *model.PropertyValue) (*model.PropertyValue, error) { + start := time.Now() + + result, err := s.PropertyValueStore.Create(value) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PropertyValueStore.Create", success, elapsed) + } + return result, err +} + +func (s *TimerLayerPropertyValueStore) Delete(id string) error { + start := time.Now() + + err := s.PropertyValueStore.Delete(id) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PropertyValueStore.Delete", success, elapsed) + } + return err +} + +func (s *TimerLayerPropertyValueStore) DeleteForField(id string) error { + start := time.Now() + + err := s.PropertyValueStore.DeleteForField(id) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PropertyValueStore.DeleteForField", success, elapsed) + } + return err +} + +func (s *TimerLayerPropertyValueStore) Get(id string) (*model.PropertyValue, error) { + start := time.Now() + + result, err := s.PropertyValueStore.Get(id) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PropertyValueStore.Get", success, elapsed) + } + return result, err +} + +func (s *TimerLayerPropertyValueStore) GetMany(ids []string) ([]*model.PropertyValue, error) { + start := time.Now() + + result, err := s.PropertyValueStore.GetMany(ids) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PropertyValueStore.GetMany", success, elapsed) + } + return result, err +} + +func (s *TimerLayerPropertyValueStore) SearchPropertyValues(opts model.PropertyValueSearchOpts) ([]*model.PropertyValue, error) { + start := time.Now() + + result, err := s.PropertyValueStore.SearchPropertyValues(opts) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PropertyValueStore.SearchPropertyValues", success, elapsed) + } + return result, err +} + +func (s *TimerLayerPropertyValueStore) Update(field []*model.PropertyValue) ([]*model.PropertyValue, error) { + start := time.Now() + + result, err := s.PropertyValueStore.Update(field) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PropertyValueStore.Update", success, elapsed) + } + return result, err +} + func (s *TimerLayerReactionStore) BulkGetForPosts(postIds []string) ([]*model.Reaction, error) { start := time.Now() @@ -12479,6 +12749,9 @@ func New(childStore store.Store, metrics einterfaces.MetricsInterface) *TimerLay newStore.PostPriorityStore = &TimerLayerPostPriorityStore{PostPriorityStore: childStore.PostPriority(), Root: &newStore} newStore.PreferenceStore = &TimerLayerPreferenceStore{PreferenceStore: childStore.Preference(), Root: &newStore} newStore.ProductNoticesStore = &TimerLayerProductNoticesStore{ProductNoticesStore: childStore.ProductNotices(), Root: &newStore} + newStore.PropertyFieldStore = &TimerLayerPropertyFieldStore{PropertyFieldStore: childStore.PropertyField(), Root: &newStore} + newStore.PropertyGroupStore = &TimerLayerPropertyGroupStore{PropertyGroupStore: childStore.PropertyGroup(), Root: &newStore} + newStore.PropertyValueStore = &TimerLayerPropertyValueStore{PropertyValueStore: childStore.PropertyValue(), Root: &newStore} newStore.ReactionStore = &TimerLayerReactionStore{ReactionStore: childStore.Reaction(), Root: &newStore} newStore.RemoteClusterStore = &TimerLayerRemoteClusterStore{RemoteClusterStore: childStore.RemoteCluster(), Root: &newStore} newStore.RetentionPolicyStore = &TimerLayerRetentionPolicyStore{RetentionPolicyStore: childStore.RetentionPolicy(), Root: &newStore} diff --git a/server/channels/testlib/store.go b/server/channels/testlib/store.go index 0ca7da7b033..10ee04e69d2 100644 --- a/server/channels/testlib/store.go +++ b/server/channels/testlib/store.go @@ -114,6 +114,10 @@ func GetMockStoreForSetupFunctions() *mocks.Store { pluginStore := mocks.PluginStore{} pluginStore.On("List", mock.Anything, mock.Anything, mock.Anything).Return([]string{}, nil) + propertyGroupStore := mocks.PropertyGroupStore{} + propertyFieldStore := mocks.PropertyFieldStore{} + propertyValueStore := mocks.PropertyValueStore{} + mockStore.On("System").Return(&systemStore) mockStore.On("User").Return(&userStore) mockStore.On("Post").Return(&postStore) @@ -130,6 +134,9 @@ func GetMockStoreForSetupFunctions() *mocks.Store { mockStore.On("Group").Return(&groupStore) mockStore.On("GetDBSchemaVersion").Return(1, nil) mockStore.On("Plugin").Return(&pluginStore) + mockStore.On("PropertyGroup").Return(&propertyGroupStore) + mockStore.On("PropertyField").Return(&propertyFieldStore) + mockStore.On("PropertyValue").Return(&propertyValueStore) return &mockStore } diff --git a/server/i18n/en.json b/server/i18n/en.json index 01cfb33484b..62e0fda99a7 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -9652,6 +9652,14 @@ "id": "model.preference.is_valid.value.app_error", "translation": "Value is too long." }, + { + "id": "model.property_field.is_valid.app_error", + "translation": "Invalid property field: {{.FieldName}} ({{.Reason}})." + }, + { + "id": "model.property_value.is_valid.app_error", + "translation": "Invalid property value: {{.FieldName}} ({{.Reason}})." + }, { "id": "model.reaction.is_valid.create_at.app_error", "translation": "Create at must be a valid time." diff --git a/server/public/model/property_field.go b/server/public/model/property_field.go new file mode 100644 index 00000000000..03f798710e0 --- /dev/null +++ b/server/public/model/property_field.go @@ -0,0 +1,83 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import "net/http" + +type PropertyFieldType string + +const ( + PropertyFieldTypeText PropertyFieldType = "text" + PropertyFieldTypeSelect PropertyFieldType = "select" + PropertyFieldTypeMultiselect PropertyFieldType = "multiselect" + PropertyFieldTypeDate PropertyFieldType = "date" + PropertyFieldTypeUser PropertyFieldType = "user" + PropertyFieldTypeMultiuser PropertyFieldType = "multiuser" +) + +type PropertyField struct { + ID string `json:"id"` + GroupID string `json:"group_id"` + Name string `json:"name"` + Type PropertyFieldType `json:"type"` + Attrs map[string]any `json:"attrs"` + TargetID string `json:"target_id"` + TargetType string `json:"target_type"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + DeleteAt int64 `json:"delete_at"` +} + +func (pf *PropertyField) PreSave() { + if pf.ID == "" { + pf.ID = NewId() + } + + if pf.CreateAt == 0 { + pf.CreateAt = GetMillis() + } + pf.UpdateAt = pf.CreateAt +} + +func (pf *PropertyField) IsValid() error { + if !IsValidId(pf.ID) { + return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "id", "Reason": "invalid id"}, "", http.StatusBadRequest) + } + + if !IsValidId(pf.GroupID) { + return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "group_id", "Reason": "invalid id"}, "id="+pf.ID, http.StatusBadRequest) + } + + if pf.Name == "" { + return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "name", "Reason": "value cannot be empty"}, "id="+pf.ID, http.StatusBadRequest) + } + + if !(pf.Type == PropertyFieldTypeText || + pf.Type == PropertyFieldTypeSelect || + pf.Type == PropertyFieldTypeMultiselect || + pf.Type == PropertyFieldTypeDate || + pf.Type == PropertyFieldTypeUser || + pf.Type == PropertyFieldTypeMultiuser) { + return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "type", "Reason": "unknown value"}, "id="+pf.ID, http.StatusBadRequest) + } + + if pf.CreateAt == 0 { + return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "create_at", "Reason": "value cannot be zero"}, "id="+pf.ID, http.StatusBadRequest) + } + + if pf.UpdateAt == 0 { + return NewAppError("PropertyField.IsValid", "model.property_field.is_valid.app_error", map[string]any{"FieldName": "update_at", "Reason": "value cannot be zero"}, "id="+pf.ID, http.StatusBadRequest) + } + + return nil +} + +type PropertyFieldSearchOpts struct { + GroupID string + TargetType string + TargetID string + IncludeDeleted bool + Page int + PerPage int +} diff --git a/server/public/model/property_group.go b/server/public/model/property_group.go new file mode 100644 index 00000000000..b8a28fe5f28 --- /dev/null +++ b/server/public/model/property_group.go @@ -0,0 +1,15 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +type PropertyGroup struct { + ID string + Name string +} + +func (pg *PropertyGroup) PreSave() { + if pg.ID == "" { + pg.ID = NewId() + } +} diff --git a/server/public/model/property_value.go b/server/public/model/property_value.go new file mode 100644 index 00000000000..244fed68479 --- /dev/null +++ b/server/public/model/property_value.go @@ -0,0 +1,71 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import "net/http" + +type PropertyValue struct { + ID string `json:"id"` + TargetID string `json:"target_id"` + TargetType string `json:"target_type"` + GroupID string `json:"group_id"` + FieldID string `json:"field_id"` + Value string `json:"value"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + DeleteAt int64 `json:"delete_at"` +} + +func (pv *PropertyValue) PreSave() { + if pv.ID == "" { + pv.ID = NewId() + } + + if pv.CreateAt == 0 { + pv.CreateAt = GetMillis() + } + pv.UpdateAt = pv.CreateAt +} + +func (pv *PropertyValue) IsValid() error { + if !IsValidId(pv.ID) { + return NewAppError("PropertyValue.IsValid", "model.property_value.is_valid.app_error", map[string]any{"FieldName": "id", "Reason": "invalid id"}, "", http.StatusBadRequest) + } + + if !IsValidId(pv.TargetID) { + return NewAppError("PropertyValue.IsValid", "model.property_value.is_valid.app_error", map[string]any{"FieldName": "target_id", "Reason": "invalid id"}, "id="+pv.ID, http.StatusBadRequest) + } + + if pv.TargetType == "" { + return NewAppError("PropertyValue.IsValid", "model.property_value.is_valid.app_error", map[string]any{"FieldName": "target_type", "Reason": "value cannot be empty"}, "id="+pv.ID, http.StatusBadRequest) + } + + if !IsValidId(pv.GroupID) { + return NewAppError("PropertyValue.IsValid", "model.property_value.is_valid.app_error", map[string]any{"FieldName": "group_id", "Reason": "invalid id"}, "id="+pv.ID, http.StatusBadRequest) + } + + if !IsValidId(pv.FieldID) { + return NewAppError("PropertyValue.IsValid", "model.property_value.is_valid.app_error", map[string]any{"FieldName": "field_id", "Reason": "invalid id"}, "id="+pv.ID, http.StatusBadRequest) + } + + if pv.CreateAt == 0 { + return NewAppError("PropertyValue.IsValid", "model.property_value.is_valid.app_error", map[string]any{"FieldName": "create_at", "Reason": "value cannot be zero"}, "id="+pv.ID, http.StatusBadRequest) + } + + if pv.UpdateAt == 0 { + return NewAppError("PropertyValue.IsValid", "model.property_value.is_valid.app_error", map[string]any{"FieldName": "update_at", "Reason": "value cannot be zero"}, "id="+pv.ID, http.StatusBadRequest) + } + + return nil +} + +type PropertyValueSearchOpts struct { + GroupID string + TargetType string + TargetID string + FieldID string + IncludeDeleted bool + Page int + PerPage int +}