From c15843217f93af7221d78447603c8a4457b61354 Mon Sep 17 00:00:00 2001
From: Semyon Krestyaninov <krestyaninov@perx.ru>
Date: Mon, 9 Jun 2025 15:52:07 +0300
Subject: [PATCH] add ToMap and FromMap for item

---
 pkg/items/item.go      | 100 ++++++++++++++----
 pkg/items/item_test.go | 235 +++++++++++++++++++++++++++++++++++++++++
 2 files changed, 313 insertions(+), 22 deletions(-)

diff --git a/pkg/items/item.go b/pkg/items/item.go
index b211b6d7..86af196a 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"
 )
@@ -169,29 +170,84 @@ func (i *Item) Clone() *Item {
 	return &itm
 }
 
-func (i *Item) ToMap() map[string]interface{} {
-	return map[string]interface{}{
-		"id":                   i.ID,
-		"space_id":             i.SpaceID,
-		"env_id":               i.EnvID,
-		"collection_id":        i.CollectionID,
-		"state":                i.State,
-		"created_rev_at":       i.CreatedRevAt,
-		"created_by":           i.CreatedBy,
-		"created_at":           i.CreatedAt,
-		"updated_at":           i.UpdatedAt,
-		"updated_by":           i.UpdatedBy,
-		"revision_id":          i.RevisionID,
-		"revision_description": i.RevisionDescription,
-		"data":                 i.Data,
-		"locale_id":            i.LocaleID,
-		"translations":         i.Translations,
-		"translations_ids":     i.TranslationsIDs,
-		"deleted":              i.Deleted,
-		"hidden":               i.Hidden,
-		"template":             i.Template,
-		"search_score":         i.SearchScore,
+// 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 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 устанавливает перевод в нужное поле записи
diff --git a/pkg/items/item_test.go b/pkg/items/item_test.go
index 1344444e..44e96946 100644
--- a/pkg/items/item_test.go
+++ b/pkg/items/item_test.go
@@ -506,3 +506,238 @@ 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(),
+		),
+	)
+
+	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),
+					},
+				},
+				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",
+					},
+				},
+				"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,
+			},
+			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(),
+		),
+	)
+
+	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",
+					},
+				},
+				"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),
+					},
+				},
+				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