From 9b0d4c3a7ae7c8e25441e748643159609faef310 Mon Sep 17 00:00:00 2001 From: Semyon Krestyaninov <krestyaninov@perx.ru> Date: Mon, 23 Jun 2025 11:35:51 +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=D0=B0=20=D0=B2=D1=8B=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=B7=D0=BA=D0=B0=20=D0=BF=D0=BE=D0=BB=D0=B5=D0=B9=20Item=20?= =?UTF-8?q?=D0=B2=20JSON,=20=D0=BF=D1=80=D0=B8=D0=B2=D0=B5=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=BA=20camelCase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: https://git.perx.ru/perxis/perxis/-/issues/3296 --- pkg/items/item.go | 95 ++++++++++++++-- pkg/items/item_test.go | 240 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 329 insertions(+), 6 deletions(-) diff --git a/pkg/items/item.go b/pkg/items/item.go index 92ef2494..d04cf999 100644 --- a/pkg/items/item.go +++ b/pkg/items/item.go @@ -13,6 +13,7 @@ import ( "git.perx.ru/perxis/perxis-go/pkg/schema/localizer" "git.perx.ru/perxis/perxis-go/pkg/schema/walk" pb "git.perx.ru/perxis/perxis-go/proto/items" + "github.com/mitchellh/mapstructure" "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -106,7 +107,7 @@ type Item struct { // При создании или обновлении идентификатор локали в котором создается запись, опционально. // Если указан, то создается перевод для указанного языка, поле translations игнорируется - LocaleID string `json:"locale_id" bson:"-"` + LocaleID string `json:"localeId,omitempty" bson:"-"` // Позволяет одновременно установить/получить несколько переводов и производить манипуляции с переводами // Ключами является идентификатор локали, значениями - данные переводы @@ -117,11 +118,11 @@ type Item struct { // - {"lang":map{...}} - установка перевода для языка // - {"lang":map{...}, "*":nil} - установка перевода для языка, сброс остальных переводов // - {"*":nil} - сброс всех переводов - Translations map[string]map[string]interface{} `json:"translations" bson:"translations,omitempty"` + Translations map[string]map[string]interface{} `json:"translations,omitempty" bson:"translations,omitempty"` // Список идентификаторов локалей, для которых есть переводы. // Соответствует ключам в translations - TranslationsIDs []string `json:"translations_ids" bson:"translations_ids,omitempty"` + TranslationsIDs []string `json:"translationsIds,omitempty" bson:"translations_ids,omitempty"` RevisionID string `json:"revId,omitempty" bson:"revision_id"` RevisionDescription string `json:"revDescription,omitempty" bson:"revision_description"` @@ -130,9 +131,9 @@ type Item struct { // Релеватность элемента при полнотекстовом поиске SearchScore float64 `json:"searchScore,omitempty" bson:"search_score,omitempty"` - Deleted bool `json:"deleted" bson:"deleted,omitempty"` - Hidden bool `json:"hidden" bson:"hidden,omitempty"` - Template bool `json:"template" bson:"template,omitempty"` + Deleted bool `json:"deleted,omitempty" bson:"deleted,omitempty"` + Hidden bool `json:"hidden,omitempty" bson:"hidden,omitempty"` + Template bool `json:"template,omitempty" bson:"template,omitempty"` } func NewItem(spaceID, envID, collID, id string, data map[string]interface{}, translations map[string]map[string]interface{}) *Item { @@ -169,6 +170,8 @@ func (i *Item) Clone() *Item { return &itm } +// ToMap конвертирует текущий элемент в map[string]any. +// DEPRECATED, используйте ToMap. func (i *Item) ToMap() map[string]interface{} { return map[string]interface{}{ "id": i.ID, @@ -194,6 +197,86 @@ func (i *Item) ToMap() map[string]interface{} { } } +// ToMap конвертирует переданный Item в map[string]any, кодируя данные согласно схеме. +// Вычисляемые поля удаляются из результата. +func ToMap(item *Item, sch *schema.Schema) (map[string]any, error) { + if item == nil { + return nil, errors.New("item must not be nil") + } + if sch == nil { + return nil, errors.New("schema must not be nil") + } + + output := make(map[string]any) + + decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + TagName: "json", + Result: &output, + }) + if err != nil { + return nil, errors.Wrap(err, "failed to create decoder") + } + + item, err = item.Encode(context.Background(), sch) + if err != nil { + return nil, errors.Wrap(err, "failed to encode item by schema") + } + + err = decoder.Decode(item) + if err != nil { + return nil, errors.Wrap(err, "failed to encode item") + } + + // Кодируем системные поля со временем + output["createdRevAt"] = item.CreatedRevAt.Format(time.RFC3339) + output["createdAt"] = item.CreatedAt.Format(time.RFC3339) + output["updatedAt"] = item.UpdatedAt.Format(time.RFC3339) + + // Удаляем вычисляемые поля + delete(output, "permissions") + delete(output, "searchScore") + + return output, nil +} + +// FromMap конвертирует переданный map[string]any в Item, декодируя данные согласно схеме. +// Вычисляемые поля игнорируются при декодировании. +func FromMap(input map[string]any, sch *schema.Schema) (*Item, error) { + if len(input) == 0 { + return nil, errors.New("input map must not be empty or nil") + } + if sch == nil { + return nil, errors.New("schema must not be nil") + } + + item := &Item{} + + decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + DecodeHook: mapstructure.StringToTimeHookFunc(time.RFC3339), + TagName: "json", + Result: item, + }) + if err != nil { + return nil, errors.Wrap(err, "failed to create decoder") + } + + // Удаляем вычисляемые поля + delete(input, "permissions") + delete(input, "searchScore") + + err = decoder.Decode(input) + if err != nil { + return nil, errors.Wrap(err, "failed to decode") + } + + item, err = item.Decode(context.Background(), sch) + if err != nil { + return nil, errors.Wrap(err, "failed to decode item by schema") + } + + return item, nil +} + // SetData устанавливает перевод в нужное поле записи func (i *Item) SetData(dt map[string]interface{}, localeID string) { if localeID == "" || localeID == locales.DefaultID { diff --git a/pkg/items/item_test.go b/pkg/items/item_test.go index 1344444e..734883bb 100644 --- a/pkg/items/item_test.go +++ b/pkg/items/item_test.go @@ -506,3 +506,243 @@ func Test_mergeItemData(t *testing.T) { }) } } + +func TestToMap(t *testing.T) { + sch := schema.New( + "str", field.String(), + "num", field.Number(field.NumberFormatFloat), + "embedded", field.Object( + "now", field.Time(), + ), + "timestamp", field.Timestamp(), + ) + + tests := []struct { + name string + input *Item + sch *schema.Schema + want map[string]any + assertErr assert.ErrorAssertionFunc + }{ + { + name: "nil item", + input: nil, + sch: nil, + want: nil, + assertErr: assert.Error, + }, + { + name: "nil schema", + input: &Item{}, + sch: nil, + want: nil, + assertErr: assert.Error, + }, + { + name: "simple", + input: &Item{ + ID: "item_id", + SpaceID: "space_id", + EnvID: "env_id", + CollectionID: "coll_id", + State: StatePublished, + CreatedRevAt: time.Date(2024, time.August, 12, 00, 0, 0, 0, time.UTC), + CreatedBy: "created_by", + CreatedAt: time.Date(2024, time.August, 12, 00, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2024, time.August, 12, 00, 0, 0, 0, time.UTC), + UpdatedBy: "updated_by", + Data: map[string]any{ + "str": "string", + "num": 2.7, + "embedded": map[string]any{ + "now": time.Date(2024, time.August, 12, 00, 0, 0, 0, time.UTC), + }, + "timestamp": 1723420800, + }, + LocaleID: "locale_id", + Translations: map[string]map[string]any{ + "ru": { + "str": "строка", + "num": 3.14, + }, + }, + TranslationsIDs: []string{"ru"}, + RevisionID: "rev_id", + RevisionDescription: "rev_desc", + Permissions: &Permissions{ + Edit: true, + Archive: false, + Publish: true, + SoftDelete: false, + HardDelete: false, + }, + SearchScore: 123.0, + Deleted: false, + Hidden: true, + Template: true, + }, + sch: sch, + want: map[string]any{ + "id": "item_id", + "spaceId": "space_id", + "envId": "env_id", + "collectionId": "coll_id", + "state": StatePublished, + "createdRevAt": "2024-08-12T00:00:00Z", + "createdBy": "created_by", + "createdAt": "2024-08-12T00:00:00Z", + "updatedAt": "2024-08-12T00:00:00Z", + "updatedBy": "updated_by", + "data": map[string]any{ + "str": "string", + "num": 2.7, + "embedded": map[string]any{ + "now": "2024-08-12T00:00:00Z", + }, + "timestamp": int64(1723420800), + }, + "localeId": "locale_id", + "translations": map[string]map[string]any{ + "ru": { + "str": "строка", + "num": 3.14, + }, + }, + "translationsIds": []string{"ru"}, + "revId": "rev_id", + "revDescription": "rev_desc", + "hidden": true, + "template": true, + }, + assertErr: assert.NoError, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := ToMap(tc.input, tc.sch) + tc.assertErr(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestFromMap(t *testing.T) { + sch := schema.New( + "str", field.String(), + "num", field.Number(field.NumberFormatFloat), + "embedded", field.Object( + "now", field.Time(), + ), + "timestamp", field.Timestamp(), + ) + + tests := []struct { + name string + input map[string]any + want *Item + sch *schema.Schema + assertErr assert.ErrorAssertionFunc + }{ + { + name: "nil item", + input: nil, + sch: nil, + want: nil, + assertErr: assert.Error, + }, + { + name: "empty item", + input: map[string]any{}, + sch: nil, + want: nil, + assertErr: assert.Error, + }, + { + name: "nil schema", + input: map[string]any{}, + sch: nil, + assertErr: assert.Error, + }, + { + name: "simple", + input: map[string]any{ + "id": "item_id", + "spaceId": "space_id", + "envId": "env_id", + "collectionId": "coll_id", + "state": StatePublished, + "createdRevAt": "2024-08-12T00:00:00Z", + "createdBy": "created_by", + "createdAt": "2024-08-12T00:00:00Z", + "updatedAt": "2024-08-12T00:00:00Z", + "updatedBy": "updated_by", + "data": map[string]any{ + "str": "string", + "num": 2.7, + "embedded": map[string]any{ + "now": "2024-08-12T00:00:00Z", + }, + "timestamp": 1723420800, + }, + "localeId": "locale_id", + "translations": map[string]map[string]any{ + "ru": { + "str": "строка", + "num": 3.14, + }, + }, + "translationsIds": []string{"ru"}, + "revId": "rev_id", + "revDescription": "rev_desc", + "deleted": false, + "hidden": true, + "template": true, + }, + sch: sch, + want: &Item{ + ID: "item_id", + SpaceID: "space_id", + EnvID: "env_id", + CollectionID: "coll_id", + State: StatePublished, + CreatedRevAt: time.Date(2024, time.August, 12, 00, 0, 0, 0, time.UTC), + CreatedBy: "created_by", + CreatedAt: time.Date(2024, time.August, 12, 00, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2024, time.August, 12, 00, 0, 0, 0, time.UTC), + UpdatedBy: "updated_by", + Data: map[string]any{ + "str": "string", + "num": 2.7, + "embedded": map[string]any{ + "now": time.Date(2024, time.August, 12, 00, 0, 0, 0, time.UTC), + }, + "timestamp": int64(1723420800), + }, + LocaleID: "locale_id", + Translations: map[string]map[string]any{ + "ru": { + "str": "строка", + "num": 3.14, + }, + }, + TranslationsIDs: []string{"ru"}, + RevisionID: "rev_id", + RevisionDescription: "rev_desc", + Permissions: nil, + SearchScore: 0.0, + Deleted: false, + Hidden: true, + Template: true, + }, + assertErr: assert.NoError, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := FromMap(tc.input, sch) + tc.assertErr(t, err) + assert.Equal(t, tc.want, got) + }) + } +} -- GitLab