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