From 8203a34c9e9d0f70a3e796e82ed44ee884b946dc Mon Sep 17 00:00:00 2001 From: Alex Petraky <petraky@perx.ru> Date: Fri, 6 Jun 2025 13:50:15 +0000 Subject: [PATCH] =?UTF-8?q?fix(core):=20=D0=98=D1=81=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82?= =?UTF-8?q?=20json-=D1=82=D0=B5=D0=B3=D0=BE=D0=B2=20=D0=B2=20CamelCase=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20Collections,=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4?= =?UTF-8?q?=D1=8B=20ToMap,=20FromMap,=20FromAny?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: #3303 --- pkg/collections/collection.go | 136 +++++++-- pkg/collections/collection_test.go | 441 +++++++++++++++++++++++++++++ pkg/schema/schema.go | 52 ++++ pkg/schema/schema_test.go | 100 +++++++ 4 files changed, 704 insertions(+), 25 deletions(-) diff --git a/pkg/collections/collection.go b/pkg/collections/collection.go index 3e574d40..44950621 100644 --- a/pkg/collections/collection.go +++ b/pkg/collections/collection.go @@ -6,8 +6,14 @@ import ( "time" "git.perx.ru/perxis/perxis-go/pkg/data" + "git.perx.ru/perxis/perxis-go/pkg/errors" "git.perx.ru/perxis/perxis-go/pkg/permission" "git.perx.ru/perxis/perxis-go/pkg/schema" + "github.com/mitchellh/mapstructure" +) + +var ( + ErrNilCollection = errors.New("collection is nil") ) // Config @@ -58,35 +64,36 @@ func (a Access) Can(action permission.Action) bool { } type Collection struct { - ID string `json:"id" bson:"id"` - SpaceID string `json:"spaceId" bson:"-"` - EnvID string `json:"envId" bson:"-"` - Name string `json:"name" bson:"name"` - Single *bool `json:"single" bson:"single,omitempty"` // В коллекции может быть только один документ - System *bool `json:"system" bson:"system,omitempty"` // Системная коллекция - NoData *bool `json:"no_data" bson:"no_data"` // Коллекция не содержит элементы. Схема используется для включения в другие схемы - Hidden bool `json:"hidden" bson:"hidden"` // Коллекция скрыта в административном интерфейсе - - NoArchive bool `json:"no_archive" bson:"no_archive,omitempty"` // Коллекция без архива - - NoRevisions bool `json:"no_revisions" bson:"no_revisions,omitempty"` // Не хранить историю изменений - MaxRevisions uint32 `json:"max_revisions" bson:"max_revisions,omitempty"` // Максимальное количество хранимых ревизий - RevisionTTL time.Duration `json:"revision_ttl" bson:"revision_ttl,omitempty"` // Время жизни ревизии + ID string `json:"id" bson:"id"` + SpaceID string `json:"spaceId" bson:"-"` + EnvID string `json:"envId" bson:"-"` + Name string `json:"name" bson:"name"` + Single *bool `json:"single,omitempty" bson:"single,omitempty"` // В коллекции может быть только один документ + System *bool `json:"system,omitempty" bson:"system,omitempty"` // Системная коллекция + NoData *bool `json:"noData,omitempty" bson:"no_data,omitempty"` // Коллекция не содержит элементы. Схема используется для включения в другие схемы + Hidden bool `json:"hidden,omitempty" bson:"hidden,omitempty"` // Коллекция скрыта в административном интерфейсе + + NoArchive bool `json:"noArchive,omitempty" bson:"no_archive,omitempty"` // Коллекция без архива + + NoRevisions bool `json:"noRevisions,omitempty" bson:"no_revisions,omitempty"` // Не хранить историю изменений + MaxRevisions uint32 `json:"maxRevisions,omitempty" bson:"max_revisions,omitempty"` // Максимальное количество хранимых + // ревизий + RevisionTTL time.Duration `json:"revisionTtl,omitempty" bson:"revision_ttl,omitempty"` // Время жизни ревизии // Все записи коллекции считаются опубликованными, функции публикации и снятия с публикации недоступны. // При включении параметра коллекции "без публикации" все записи, независимо от статуса, будут считаться опубликованными. // При отключении параметра "без публикации" статусы публикации будут восстановлены. - NoPublish bool `json:"no_publish" bson:"no_publish,omitempty"` + NoPublish bool `json:"noPublish,omitempty" bson:"no_publish,omitempty"` - Schema *schema.Schema `json:"schema" bson:"schema"` - Access *Access `json:"access" bson:"-"` // Ограничения на доступ к элементам коллекции. Отсутствие объекта означает неограниченный доступ + Schema *schema.Schema `json:"schema,omitempty" bson:"schema"` + Access *Access `json:"access,omitempty" bson:"-"` // Ограничения на доступ к элементам коллекции. Отсутствие объекта означает неограниченный доступ // StateInfo отображает состояние коллекции: // - State: идентификатор состояния коллекции (new/preparing/ready/error/changed) // - Info: дополнительная информация о состоянии коллекции (например, если при // применении схемы к коллекции произошла ошибка) // - StartedAt: время, в которое коллекция перешла в состояние `Preparing` - StateInfo *StateInfo `json:"state_info" bson:"state_info,omitempty"` // todo: показывать в интерфейсе как readonly + StateInfo *StateInfo `json:"stateInfo,omitempty" bson:"state_info,omitempty"` // todo: показывать в интерфейсе как readonly // View - Если значение поля непустое, то коллекция является View ("отображением" // части данных другой коллекции согласно View.Filter) @@ -148,10 +155,10 @@ func IsEqual(c1, c2 *Collection) bool { } type View struct { - SpaceID string `json:"space_id" bson:"space_id"` // SpaceID оригинальной коллекции - EnvID string `json:"environment_id" bson:"environment_id"` // EnvID оригинальной коллекции - CollectionID string `json:"collection_id" bson:"collection_id"` // CollectionID оригинальной коллекции - Filter string `json:"filter" bson:"filter,omitempty"` // Правила фильтрации записей оригинальной коллекции + SpaceID string `json:"spaceId" bson:"space_id"` // SpaceID оригинальной коллекции + EnvID string `json:"environmentId" bson:"environment_id"` // EnvID оригинальной коллекции + CollectionID string `json:"collectionId" bson:"collection_id"` // CollectionID оригинальной коллекции + Filter string `json:"filter,omitempty" bson:"filter,omitempty"` // Правила фильтрации записей оригинальной коллекции } func (v *View) Equal(v1 *View) bool { @@ -163,10 +170,10 @@ func (v *View) Equal(v1 *View) bool { } type StateInfo struct { - State State `json:"state" bson:"state"` - Info string `json:"info" bson:"info"` + State State `json:"state" bson:"state"` + Info string `json:"info" bson:"info"` StartedAt time.Time `json:"started_at,omitempty" bson:"started_at,omitempty"` - DBVersion uint32 `json:"db_version" bson:"db_version"` + DBVersion uint32 `json:"db_version" bson:"db_version"` } type State int @@ -422,3 +429,82 @@ func (c *Collection) mergeComplexFields(other *Collection) { func Merge(c1, c2 *Collection) *Collection { return c1.Merge(c2) } + +// ToMap преобразует структуру Collection в map[string]interface{}. +func ToMap(c *Collection) (map[string]interface{}, error) { + if c == nil { + return nil, ErrNilCollection + } + + result := make(map[string]interface{}) + decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{TagName: "json", Result: &result}) + if err != nil { + return nil, errors.Errorf("failed to create decoder: %w", err) + } + if err := decoder.Decode(c); err != nil { + return nil, errors.Errorf("failed to decode collection: %w", err) + } + + if c.Schema != nil { + result["schema"] = schema.ToMap(c.Schema) + } + + if c.RevisionTTL != 0 { + result["revisionTtl"] = c.RevisionTTL.String() + } + + delete(result, "access") + delete(result, "stateInfo") + + return result, nil +} + +// FromMap преобразует map[string]interface{} в структуру Collection. +func FromMap(m map[string]interface{}) (*Collection, error) { + if m == nil { + return nil, ErrNilCollection + } + + coll := &Collection{} + structuredWithoutSpecial := make(map[string]interface{}) + for k, v := range m { + if k != "schema" && k != "revisionTtl" { + structuredWithoutSpecial[k] = v + } + } + + decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{TagName: "json", Result: coll}) + if err != nil { + return nil, errors.Errorf("failed to create decoder: %w", err) + } + + if err := decoder.Decode(structuredWithoutSpecial); err != nil { + return nil, errors.Errorf("failed to decode map: %w", err) + } + + if sch, ok := m["schema"]; ok && sch != nil { + v, err := schema.FromAny(sch) + if err != nil { + return nil, err + } + coll.Schema = v + } + + if ttl, ok := m["revisionTtl"]; ok && ttl != nil { + switch v := ttl.(type) { + case string: + coll.RevisionTTL, err = time.ParseDuration(v) + if err != nil { + return nil, err + } + case int64: + coll.RevisionTTL = time.Duration(v) + case float64: + coll.RevisionTTL = time.Duration(v) + default: + return nil, errors.New("invalid revisionTtl type") + } + } + + return coll, nil +} diff --git a/pkg/collections/collection_test.go b/pkg/collections/collection_test.go index 84e0d5e5..13d1df73 100644 --- a/pkg/collections/collection_test.go +++ b/pkg/collections/collection_test.go @@ -5,9 +5,15 @@ import ( "time" "git.perx.ru/perxis/perxis-go/pkg/schema" + "git.perx.ru/perxis/perxis-go/pkg/schema/field" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func ptr[T any](v T) *T { + return &v +} + func TestView_Equal(t *testing.T) { testCases := []struct { name string @@ -324,3 +330,438 @@ func Test_Merge(t *testing.T) { }) } } + +func TestCollection_ToMap(t *testing.T) { + testCases := []struct { + name string + c *Collection + expected map[string]interface{} + wantErr bool + }{ + { + name: "Nil collection", + c: nil, + expected: nil, + wantErr: true, + }, + { + name: "Empty collection", + c: &Collection{}, + expected: map[string]interface{}{ + "id": "", + "spaceId": "", + "envId": "", + "name": "", + }, + }, + { + name: "Collection with all fields", + c: &Collection{ + ID: "id", + SpaceID: "space", + EnvID: "env", + Name: "name", + Single: ptr(true), + System: ptr(true), + NoData: ptr(true), + Hidden: true, + NoArchive: true, + NoRevisions: true, + MaxRevisions: 10, + RevisionTTL: time.Hour, + NoPublish: true, + Tags: []string{"tag1", "tag2"}, + Translations: map[string]map[string]string{ + "ru": {"key": "значение"}, + "en": {"key": "value"}, + }, + Schema: schema.New("text", field.String()), + }, + expected: map[string]interface{}{ + "id": "id", + "spaceId": "space", + "envId": "env", + "name": "name", + "single": ptr(true), + "system": ptr(true), + "noData": ptr(true), + "hidden": true, + "noArchive": true, + "noRevisions": true, + "maxRevisions": uint32(10), + "revisionTtl": "1h0m0s", + "noPublish": true, + "tags": []string{"tag1", "tag2"}, + "translations": map[string]map[string]string{ + "ru": {"key": "значение"}, + "en": {"key": "value"}, + }, + "schema": map[string]interface{}{ + "loaded": false, + "metadata": interface{}(nil), + "params": map[string]interface{}{ + "fields": map[string]interface{}{"text": map[string]interface{}{ + "params": map[string]interface{}{}, + "type": "string", + }}, "inline": false}, + "type": "object", + "ui": map[string]interface{}{"options": map[string]interface{}{"fields": []interface{}{"text"}}}, + }, + }, + }, + { + name: "Collection with view", + c: &Collection{ + ID: "id", + SpaceID: "space", + EnvID: "env", + Name: "name", + View: &View{ + SpaceID: "viewSpace", + EnvID: "viewEnv", + CollectionID: "viewCollection", + Filter: "viewFilter", + }, + }, + expected: map[string]interface{}{ + "id": "id", + "spaceId": "space", + "envId": "env", + "name": "name", + "view": map[string]interface{}{ + "spaceId": "viewSpace", + "environmentId": "viewEnv", + "collectionId": "viewCollection", + "filter": "viewFilter", + }, + }, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + result, err := ToMap(tt.c) + if tt.wantErr { + require.Error(t, err) + assert.Equal(t, tt.expected, result) + return + } + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestCollection_FromMap(t *testing.T) { + strSchema := `{ + "ui": { + "options": { + "fields": [ + "text" + ] + } + }, + "type": "object", + "params": { + "inline": false, + "fields": { + "text": { + "title": "text", + "ui": { + "widget": "StringInput" + }, + "type": "string", + "params": {} + } + } + }, + "loaded": false, + "metadata": null +}` + + testCases := []struct { + name string + data map[string]interface{} + expected *Collection + wantErr bool + }{ + { + name: "Nil data", + data: nil, + expected: nil, + wantErr: true, + }, + { + name: "Basic fields", + data: map[string]interface{}{ + "id": "id", + "spaceId": "space", + "envId": "env", + "name": "name", + "single": true, + "system": true, + "noData": true, + "hidden": true, + "noArchive": true, + "noRevisions": true, + "maxRevisions": float64(10), + "revisionTtl": "1h0m0s", + "noPublish": true, + "tags": []string{"tag1", "tag2"}, + "translations": map[string]interface{}{ + "ru": map[string]string{"key": "значение"}, + "en": map[string]string{"key": "value"}, + }, + "schema": map[string]interface{}{ + "loaded": false, + "metadata": interface{}(nil), + "params": map[string]interface{}{ + "fields": map[string]interface{}{ + "text": map[string]interface{}{"params": map[string]interface{}{}, "type": "string"}, + }, + "inline": false, + }, + "type": "object", + "ui": map[string]interface{}{ + "options": map[string]interface{}{"fields": []interface{}{"text"}}, + }, + }, + }, + expected: &Collection{ + ID: "id", + SpaceID: "space", + EnvID: "env", + Name: "name", + Single: ptr(true), + System: ptr(true), + NoData: ptr(true), + Hidden: true, + NoArchive: true, + NoRevisions: true, + MaxRevisions: 10, + RevisionTTL: time.Hour, + NoPublish: true, + Tags: []string{"tag1", "tag2"}, + Translations: map[string]map[string]string{ + "ru": {"key": "значение"}, + "en": {"key": "value"}, + }, + Schema: schema.New("text", field.String()).ClearState(), + }, + wantErr: false, + }, + { + name: "With string schema", + data: map[string]interface{}{ + "id": "id", + "spaceId": "space", + "envId": "env", + "name": "name", + "schema": strSchema, + }, + expected: &Collection{ + ID: "id", + SpaceID: "space", + EnvID: "env", + Name: "name", + Schema: schema.New("text", field.String().SetTitle("text"). + WithUI(&field.UI{Widget: "StringInput"})).ClearState(), + }, + wantErr: false, + }, + { + name: "Collection with view", + data: map[string]interface{}{ + "id": "id", + "spaceId": "space", + "envId": "env", + "name": "name", + "view": map[string]interface{}{ + "spaceId": "viewSpace", + "environmentId": "viewEnv", + "collectionId": "viewCollection", + "filter": "viewFilter", + }, + }, + expected: &Collection{ + ID: "id", + SpaceID: "space", + EnvID: "env", + Name: "name", + View: &View{ + SpaceID: "viewSpace", + EnvID: "viewEnv", + CollectionID: "viewCollection", + Filter: "viewFilter", + }, + }, + wantErr: false, + }, + { + name: "Invalid revisionTtl", + data: map[string]interface{}{ + "revisionTtl": "invalid", + }, + expected: &Collection{}, + wantErr: true, + }, + { + name: "Invalid schema", + data: map[string]interface{}{ + "schema": "invalid", + }, + expected: &Collection{}, + wantErr: true, + }, + { + name: "RevisionTtl as number", + data: map[string]interface{}{ + "revisionTtl": float64(3600), + }, + expected: &Collection{ + RevisionTTL: time.Duration(3600), + }, + wantErr: false, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + c, err := FromMap(tt.data) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tt.expected, c) + } + }) + } +} + +func TestCollection_EncodeDecodeCycle(t *testing.T) { + testCases := []struct { + name string + c *Collection + }{ + { + name: "Empty collection", + c: &Collection{}, + }, + { + name: "Collection with all fields (without schema)", + c: &Collection{ + ID: "id", + SpaceID: "space", + EnvID: "env", + Name: "name", + Single: ptr(true), + System: ptr(true), + NoData: ptr(true), + Hidden: true, + NoArchive: true, + NoRevisions: true, + MaxRevisions: 10, + RevisionTTL: time.Hour, + NoPublish: true, + Tags: []string{"tag1", "tag2"}, + Translations: map[string]map[string]string{ + "ru": { + "name": "Web Блоки/Вкладки", + "description": "Описание", + "title": "Заголовок", + "label": "Метка", + }, + "en": { + "name": "Web Blocks/Tabs", + "description": "Description", + "title": "Title", + "label": "Label", + }, + }, + }, + }, + { + name: "Collection with schema", + c: &Collection{ + ID: "id", + SpaceID: "space", + EnvID: "env", + Name: "name", + Schema: schema.New( + "field1", field.String(), + "field2", field.Number(field.NumberFormatInt), + "field3", field.Object( + "nested1", field.String(), + "nested2", field.Bool(), + ), + ), + }, + }, + { + name: "Collection with view", + c: &Collection{ + ID: "id", + SpaceID: "space", + EnvID: "env", + Name: "name", + View: &View{ + SpaceID: "viewSpace", + EnvID: "viewEnv", + CollectionID: "viewCollection", + Filter: "viewFilter", + }, + }, + }, + { + name: "Collection with only pointers", + c: &Collection{ + ID: "id", + SpaceID: "space", + EnvID: "env", + Name: "name", + Single: ptr(false), + System: ptr(false), + NoData: ptr(false), + }, + }, + { + name: "Collection with only translations", + c: &Collection{ + ID: "id", + SpaceID: "space", + EnvID: "env", + Name: "name", + Translations: map[string]map[string]string{ + "ru": {"key": "значение"}, + "en": {"key": "value"}, + }, + }, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + // Клонируем исходную коллекцию для сравнения + original := tt.c.Clone() + if original.Schema != nil { + original.Schema.ClearState() + } + + // Выполняем encode + encoded, err := ToMap(original) + require.NoError(t, err) + + // Создаем новую коллекцию и выполняем decode + decoded, err := FromMap(encoded) + require.NoError(t, err) + + // Очищаем состояние схемы перед сравнением + if decoded.Schema != nil { + decoded.Schema.ClearState() + } + + // Сравниваем с исходной коллекцией + require.Equal(t, original, decoded) + }) + } +} diff --git a/pkg/schema/schema.go b/pkg/schema/schema.go index bdebe4f0..1979ce5c 100644 --- a/pkg/schema/schema.go +++ b/pkg/schema/schema.go @@ -2,6 +2,7 @@ package schema import ( "context" + "encoding/json" "reflect" "git.perx.ru/perxis/perxis-go/pkg/errors" @@ -9,6 +10,7 @@ import ( "git.perx.ru/perxis/perxis-go/pkg/schema/field" "git.perx.ru/perxis/perxis-go/pkg/schema/modify" "git.perx.ru/perxis/perxis-go/pkg/schema/validate" + jsoniter "github.com/json-iterator/go" ) type Schema struct { @@ -284,3 +286,53 @@ func (s *Schema) GetEnum(fieldPath string) []validate.EnumOpt { } return validate.GetEnum(f) } + +// ToMap сериализует схему в map[string]interface{}. +func ToMap(sch *Schema) map[string]interface{} { + if sch == nil { + return nil + } + + schemaBytes, err := sch.MarshalJSON() + if err != nil { + return nil + } + + var out map[string]interface{} + if err := jsoniter.Unmarshal(schemaBytes, &out); err != nil { + return nil + } + + return out +} + +// FromMap парсит схему из map[string]interface{}. +func FromMap(data map[string]interface{}) (*Schema, error) { + schemaBytes, err := json.Marshal(data) + if err != nil { + return nil, errors.Errorf("failed to marshal schema map: %w", err) + } + + var sch Schema + if err := sch.UnmarshalJSON(schemaBytes); err != nil { + return nil, errors.Errorf("failed to unmarshal schema: %w", err) + } + + return &sch, nil +} + +// FromAny парсит схему из string или map[string]interface{}. +func FromAny(data any) (*Schema, error) { + switch v := data.(type) { + case string: + var sch Schema + if err := sch.UnmarshalJSON([]byte(v)); err != nil { + return nil, errors.Errorf("failed to unmarshal schema: %w", err) + } + return &sch, nil + case map[string]interface{}: + return FromMap(v) + default: + return nil, errors.New("invalid schema type") + } +} diff --git a/pkg/schema/schema_test.go b/pkg/schema/schema_test.go index dbedf48f..a7050796 100644 --- a/pkg/schema/schema_test.go +++ b/pkg/schema/schema_test.go @@ -5,6 +5,7 @@ import ( "git.perx.ru/perxis/perxis-go/pkg/schema/field" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestSchema_Clone(t *testing.T) { @@ -16,3 +17,102 @@ func TestSchema_Clone(t *testing.T) { fld = f.Clone(false) assert.NotNil(t, fld.State) } + +func TestToMap(t *testing.T) { + tests := []struct { + name string + schema *Schema + expected map[string]interface{} + }{ + { + name: "nil schema", + schema: nil, + expected: nil, + }, + { + name: "simple schema", + schema: New("test", field.String()), + expected: map[string]interface{}{ + "type": "object", + "loaded": false, + "metadata": nil, + "params": map[string]interface{}{ + "fields": map[string]interface{}{ + "test": map[string]interface{}{ + "type": "string", + "params": map[string]interface{}{}, + }, + }, + "inline": false, + }, + "ui": map[string]interface{}{ + "options": map[string]interface{}{ + "fields": []interface{}{"test"}, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ToMap(tt.schema) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFromMap(t *testing.T) { + tests := []struct { + name string + input map[string]interface{} + expectedErr bool + }{ + { + name: "valid schema", + input: map[string]interface{}{ + "type": "object", + "loaded": false, + "metadata": nil, + "params": map[string]interface{}{ + "fields": map[string]interface{}{ + "test": map[string]interface{}{ + "type": "string", + "params": map[string]interface{}{}, + }, + }, + "inline": false, + }, + "ui": map[string]interface{}{ + "options": map[string]interface{}{ + "fields": []interface{}{"test"}, + }, + }, + }, + expectedErr: false, + }, + { + name: "invalid json", + input: map[string]interface{}{ + "type": func() {}, + }, + expectedErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := FromMap(tt.input) + if tt.expectedErr { + require.Error(t, err) + assert.Nil(t, result) + } else { + require.NoError(t, err) + assert.NotNil(t, result) + // Проверяем, что можем преобразовать обратно в map + resultMap := ToMap(result) + assert.Equal(t, tt.input, resultMap) + } + }) + } +} -- GitLab