diff --git a/pkg/collections/collection.go b/pkg/collections/collection.go index ed9f91afe038dc1ebcb4f42a91f51a7828b47cf9..3e574d4071327460ea5b49c5d5b66641655ddc6a 100644 --- a/pkg/collections/collection.go +++ b/pkg/collections/collection.go @@ -101,16 +101,27 @@ type Collection struct { } // GetID возвращает идентификатор коллекции -func (c Collection) GetID() string { +func (c *Collection) GetID() string { return c.ID } -func (c Collection) GetSpaceID() string { +func (c *Collection) GetSpaceID() string { return c.SpaceID } // Equal сравнивает две коллекции, за исключением Schema, Access, StateInfo и Config -func (c Collection) Equal(other *Collection) bool { +// Deprecated: используйте метод IsEqual +func (c *Collection) Equal(other *Collection) bool { + return c.IsEqual(other) +} + +func (c *Collection) IsEqual(other *Collection) bool { + if c == other { + return true + } + if c == nil || other == nil { + return false + } if c.ID != other.ID || c.SpaceID != other.SpaceID || c.EnvID != other.EnvID || @@ -132,6 +143,10 @@ func (c Collection) Equal(other *Collection) bool { return true } +func IsEqual(c1, c2 *Collection) bool { + return c1.IsEqual(c2) +} + type View struct { SpaceID string `json:"space_id" bson:"space_id"` // SpaceID оригинальной коллекции EnvID string `json:"environment_id" bson:"environment_id"` // EnvID оригинальной коллекции @@ -185,7 +200,7 @@ const ( StateChanged ) -func (c Collection) Clone() *Collection { +func (c *Collection) Clone() *Collection { clone := &Collection{ ID: c.ID, SpaceID: c.SpaceID, @@ -294,3 +309,116 @@ func RuleFromAccess(access *Access) *permission.Rule { Hidden: access.Hidden, } } + +// Merge объединяет две коллекции, возвращая новую коллекцию +// с объединенными данными. Если данные второй коллекции не пустые, +// то данные из второй коллекции перезаписывают данные из первой. +func (c *Collection) Merge(other *Collection) *Collection { + if c == nil { + if other == nil { + return nil + } + return other.Clone() + } + + if other == nil { + return c.Clone() + } + + merged := c.Clone() + merged.mergeScalarFields(other) + merged.mergePointerFields(other) + merged.mergeComplexFields(other) + + return merged +} + +// mergeScalarFields копирует скалярные поля из other коллекции в текущую коллекцию. +func (c *Collection) mergeScalarFields(other *Collection) { + if other.ID != "" { + c.ID = other.ID + } + if other.Name != "" { + c.Name = other.Name + } + if other.SpaceID != "" { + c.SpaceID = other.SpaceID + } + if other.EnvID != "" { + c.EnvID = other.EnvID + } + if other.Hidden { + c.Hidden = other.Hidden + } + if other.NoPublish { + c.NoPublish = other.NoPublish + } + if other.NoArchive { + c.NoArchive = other.NoArchive + } + if other.NoRevisions { + c.NoRevisions = other.NoRevisions + } + if other.MaxRevisions > 0 { + c.MaxRevisions = other.MaxRevisions + } + if other.RevisionTTL > 0 { + c.RevisionTTL = other.RevisionTTL + } +} + +// mergePointerFields копирует указатели из other коллекции в текущую коллекцию. +func (c *Collection) mergePointerFields(other *Collection) { + if other.NoData != nil { + c.NoData = other.NoData + } + if other.Single != nil { + single := *other.Single + c.Single = &single + } + if other.System != nil { + system := *other.System + c.System = &system + } + if other.StateInfo != nil { + info := *other.StateInfo + c.StateInfo = &info + } + if other.View != nil { + view := *other.View + c.View = &view + } + if other.Config != nil { + cfg := *other.Config + c.Config = &cfg + } +} + +// mergeComplexFields копирует сложные поля из other коллекции в текущую коллекцию. +func (c *Collection) mergeComplexFields(other *Collection) { + if other.Schema != nil { + c.Schema = other.Schema.Clone(false) + } + if other.Access != nil { + c.Access = other.Access.Clone() + } + if other.Tags != nil { + tags := make([]string, len(other.Tags)) + copy(tags, other.Tags) + c.Tags = tags + } + if len(other.Translations) > 0 { + translations := make(map[string]map[string]string, len(other.Translations)) + for k, v := range other.Translations { + translations[k] = maps.Clone(v) + } + c.Translations = translations + } +} + +// Merge объединяет две коллекции, возвращая новую коллекцию +// с объединенными данными. Если данные второй коллекции не пустые, +// то данные из второй коллекции перезаписывают данные из первой. +func Merge(c1, c2 *Collection) *Collection { + return c1.Merge(c2) +} diff --git a/pkg/collections/collection_test.go b/pkg/collections/collection_test.go index 79e16a8e6a397418ef2418979bf0e38947b730c8..84e0d5e509a256e6464e8e20ad9023109e97beb8 100644 --- a/pkg/collections/collection_test.go +++ b/pkg/collections/collection_test.go @@ -2,7 +2,9 @@ package collections import ( "testing" + "time" + "git.perx.ru/perxis/perxis-go/pkg/schema" "github.com/stretchr/testify/require" ) @@ -100,3 +102,225 @@ func TestView_Equal(t *testing.T) { }) } } + +func Test_Merge(t *testing.T) { + boolTrue := true + boolFalse := false + + // Create schema objects with Title field + schema1 := schema.New() + schema1.Title = "schema1" + + schema2 := schema.New() + schema2.Title = "schema2" + + testCases := []struct { + name string + c1 *Collection + c2 *Collection + want *Collection + }{ + { + name: "Both collections are nil", + c1: nil, + c2: nil, + want: nil, + }, + { + name: "First collection is nil", + c1: nil, + c2: &Collection{ + ID: "id2", + Name: "name2", + }, + want: &Collection{ + ID: "id2", + Name: "name2", + }, + }, + { + name: "Second collection is nil", + c1: &Collection{ + ID: "id1", + Name: "name1", + }, + c2: nil, + want: &Collection{ + ID: "id1", + Name: "name1", + }, + }, + { + name: "Merge primitive fields", + c1: &Collection{ + ID: "id1", + SpaceID: "space1", + EnvID: "env1", + Name: "name1", + Hidden: false, + NoPublish: false, + NoArchive: false, + NoRevisions: false, + MaxRevisions: 5, + RevisionTTL: time.Hour, + }, + c2: &Collection{ + ID: "id2", + SpaceID: "space2", + EnvID: "env2", + Name: "name2", + Hidden: true, + NoPublish: true, + NoArchive: true, + NoRevisions: true, + MaxRevisions: 10, + RevisionTTL: time.Hour * 2, + }, + want: &Collection{ + ID: "id2", + SpaceID: "space2", + EnvID: "env2", + Name: "name2", + Hidden: true, + NoPublish: true, + NoArchive: true, + NoRevisions: true, + MaxRevisions: 10, + RevisionTTL: time.Hour * 2, + }, + }, + { + name: "Merge pointer fields", + c1: &Collection{ + NoData: &boolFalse, + Single: &boolFalse, + System: &boolFalse, + }, + c2: &Collection{ + NoData: &boolTrue, + Single: &boolTrue, + System: &boolTrue, + }, + want: &Collection{ + NoData: &boolTrue, + Single: &boolTrue, + System: &boolTrue, + }, + }, + { + name: "Merge complex fields", + c1: &Collection{ + Schema: schema1, + View: &View{ + SpaceID: "space1", + EnvID: "env1", + CollectionID: "collection1", + Filter: "filter1", + }, + Tags: []string{"tag1", "tag2"}, + Translations: map[string]map[string]string{ + "en": { + "key1": "value1", + }, + }, + }, + c2: &Collection{ + Schema: schema2, + View: &View{ + SpaceID: "space2", + EnvID: "env2", + CollectionID: "collection2", + Filter: "filter2", + }, + Tags: []string{"tag3", "tag4"}, + Translations: map[string]map[string]string{ + "fr": { + "key2": "value2", + }, + }, + }, + want: &Collection{ + Schema: schema2, + View: &View{ + SpaceID: "space2", + EnvID: "env2", + CollectionID: "collection2", + Filter: "filter2", + }, + Tags: []string{"tag3", "tag4"}, + Translations: map[string]map[string]string{ + "fr": { + "key2": "value2", + }, + }, + }, + }, + { + name: "Merge with empty second collection", + c1: &Collection{ + ID: "id1", + SpaceID: "space1", + EnvID: "env1", + Name: "name1", + NoData: &boolTrue, + Single: &boolTrue, + System: &boolTrue, + Schema: schema1, + Tags: []string{"tag1", "tag2"}, + }, + c2: &Collection{}, + want: &Collection{ + ID: "id1", + SpaceID: "space1", + EnvID: "env1", + Name: "name1", + NoData: &boolTrue, + Single: &boolTrue, + System: &boolTrue, + Schema: schema1, + Tags: []string{"tag1", "tag2"}, + }, + }, + { + name: "Merge with partial second collection", + c1: &Collection{ + ID: "id1", + SpaceID: "space1", + EnvID: "env1", + Name: "name1", + NoData: &boolFalse, + Single: &boolFalse, + System: &boolFalse, + Schema: schema1, + Tags: []string{"tag1", "tag2"}, + }, + c2: &Collection{ + Name: "name2", + NoData: &boolTrue, + Schema: schema2, + }, + want: &Collection{ + ID: "id1", + SpaceID: "space1", + EnvID: "env1", + Name: "name2", + NoData: &boolTrue, + Single: &boolFalse, + System: &boolFalse, + Schema: schema2, + Tags: []string{"tag1", "tag2"}, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := Merge(tc.c1, tc.c2) + + require.True(t, IsEqual(result, tc.want)) + if result != nil && tc.want != nil { + require.True(t, schema.IsEqual(result.Schema, tc.want.Schema)) + } + }) + } +} diff --git a/pkg/items/item.go b/pkg/items/item.go index dc56fe63284be6885cf4560578df0bf77729e359..92ef2494981c12e7e28e05c474ceb0db09e3c8be 100644 --- a/pkg/items/item.go +++ b/pkg/items/item.go @@ -11,6 +11,7 @@ import ( "git.perx.ru/perxis/perxis-go/pkg/schema" "git.perx.ru/perxis/perxis-go/pkg/schema/field" "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" "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" @@ -662,3 +663,31 @@ func GetItemIDs(arr []*Item) []string { } return res } + +func MergeItemData(ctx context.Context, sch *schema.Schema, origData, updData map[string]any) (map[string]any, error) { + if origData == nil { + return updData, nil + } + + w := walk.NewWalker(sch, &walk.WalkConfig{}) + w.DefaultFn = func(c *walk.WalkContext) error { + if c.Src == nil || c.Dst != nil { + return nil + } + c.Dst = c.Src + c.Changed = true + return nil + } + + res, _, err := w.DataWalk(ctx, updData, origData) + if err != nil { + return nil, err + } + + v, ok := res.(map[string]any) + if !ok { + return nil, fmt.Errorf("expected map[string]interface{}, got %[1]T, %[1]v", res) + } + + return v, nil +} diff --git a/pkg/items/item_test.go b/pkg/items/item_test.go index 05baa60a3c1e8eaae9534fe9026d7456d78ab36c..1344444e685ee025e9b7e13a4bc2d781eb36a9de 100644 --- a/pkg/items/item_test.go +++ b/pkg/items/item_test.go @@ -264,3 +264,245 @@ func TestItem_Encode_Decode(t *testing.T) { }) } } + +func Test_mergeItemData(t *testing.T) { + tests := []struct { + name string + schema *schema.Schema + origData map[string]any + updData map[string]any + want map[string]interface{} + wantErr bool + }{ + { + name: "merge with non-nil original data", + schema: schema.New( + "field1", field.String(), + "field2", field.String(), + "field3", field.String(), + ), + origData: map[string]interface{}{ + "field1": "value1", + "field2": "value2", + }, + updData: map[string]interface{}{ + "field2": "new_value2", + "field3": "value3", + }, + want: map[string]interface{}{ + "field1": "value1", + "field2": "new_value2", + "field3": "value3", + }, + wantErr: false, + }, + { + name: "merge with nil original data", + schema: schema.New( + "field1", field.String(), + ), + origData: nil, + updData: map[string]interface{}{ + "field1": "value1", + }, + want: map[string]interface{}{ + "field1": "value1", + }, + wantErr: false, + }, + { + name: "merge with empty original data", + schema: schema.New( + "field1", field.String(), + ), + origData: map[string]interface{}{}, + updData: map[string]interface{}{ + "field1": "value1", + }, + want: map[string]interface{}{ + "field1": "value1", + }, + wantErr: false, + }, + { + name: "merge with schema fields", + schema: schema.New( + "field1", field.String(), + "field2", field.String(), + "field3", field.String(), + ), + origData: map[string]interface{}{ + "field1": "value1", + "field2": "value2", + }, + updData: map[string]interface{}{ + "field2": "new_value2", + "field3": "value3", + }, + want: map[string]interface{}{ + "field1": "value1", + "field2": "new_value2", + "field3": "value3", + }, + wantErr: false, + }, + { + name: "merge with extra fields not in schema", + schema: schema.New( + "field1", field.String(), + "field2", field.String(), + ), + origData: map[string]interface{}{ + "field1": "value1", + "extra_field": "extra_value", + }, + updData: map[string]interface{}{ + "field2": "value2", + "another_extra": "another_value", + }, + want: map[string]interface{}{ + "field1": "value1", + "field2": "value2", + }, + wantErr: false, + }, + { + name: "merge with different field types", + schema: schema.New( + "string_field", field.String(), + "number_field", field.Number(field.NumberFormatInt), + "bool_field", field.String(), + ), + origData: map[string]interface{}{ + "string_field": "old_value", + "number_field": 42, + }, + updData: map[string]interface{}{ + "string_field": "new_value", + "bool_field": "true", + }, + want: map[string]interface{}{ + "string_field": "new_value", + "number_field": 42, + "bool_field": "true", + }, + wantErr: false, + }, + { + name: "merge with nested schema", + schema: schema.New( + "user", field.Object( + "name", field.String(), + "age", field.Number(field.NumberFormatInt), + "active", field.Bool(), + ), + "metadata", field.Object( + "created_at", field.String(), + "updated_at", field.String(), + ), + ), + origData: map[string]interface{}{ + "user": map[string]interface{}{ + "name": "John", + "age": 30, + "active": true, + }, + "metadata": map[string]interface{}{ + "created_at": "2024-01-01", + }, + }, + updData: map[string]interface{}{ + "user": map[string]interface{}{ + "name": "John Doe", + }, + "metadata": map[string]interface{}{ + "updated_at": "2024-03-20", + }, + }, + want: map[string]interface{}{ + "user": map[string]interface{}{ + "name": "John Doe", + "age": 30, + "active": true, + }, + "metadata": map[string]interface{}{ + "created_at": "2024-01-01", + "updated_at": "2024-03-20", + }, + }, + wantErr: false, + }, + { + name: "merge with array fields", + schema: schema.New( + "tags", field.Array(field.String()), + "numbers", field.Array(field.Number(field.NumberFormatInt)), + "mixed", field.Array(field.String()), + ), + origData: map[string]interface{}{ + "tags": []interface{}{"tag1", "tag2"}, + "numbers": []interface{}{1, 2, 3}, + }, + updData: map[string]interface{}{ + "tags": []interface{}{"tag3", "tag4"}, + "mixed": []interface{}{"value1", "value2"}, + }, + want: map[string]interface{}{ + "tags": []interface{}{"tag3", "tag4"}, + "numbers": []interface{}{1, 2, 3}, + "mixed": []interface{}{"value1", "value2"}, + }, + wantErr: false, + }, + { + name: "merge with required fields", + schema: schema.New( + "required_field", field.String(), + "optional_field", field.String(), + ), + origData: map[string]interface{}{ + "required_field": "original", + "optional_field": "optional", + }, + updData: map[string]interface{}{ + "required_field": "updated", + }, + want: map[string]interface{}{ + "required_field": "updated", + "optional_field": "optional", + }, + wantErr: false, + }, + { + name: "merge with validation rules", + schema: schema.New( + "email", field.String(), + "age", field.Number(field.NumberFormatInt), + ), + origData: map[string]interface{}{ + "email": "test@example.com", + "age": 25, + }, + updData: map[string]interface{}{ + "email": "new@example.com", + "age": 30, + }, + want: map[string]interface{}{ + "email": "new@example.com", + "age": 30, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := MergeItemData(context.Background(), tt.schema, tt.origData, tt.updData) + if (err != nil) != tt.wantErr { + t.Errorf("mergeItemData() error = %v, wantErr %v", err, tt.wantErr) + return + } + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/schema/schema.go b/pkg/schema/schema.go index 7b337f8ab68f732fb3ab2ac804fe89e082c58c6a..bdebe4f04025278d00ec41b20bba2b596ac5de8a 100644 --- a/pkg/schema/schema.go +++ b/pkg/schema/schema.go @@ -48,14 +48,24 @@ func (s *Schema) ClearState() *Schema { return s } -func (s *Schema) Equal(sch *Schema) bool { - if s == sch { +// Equal compares two schemas for equality. +// Deprecated: use `IsEqual` method instead. +func (s *Schema) Equal(other *Schema) bool { + return s.IsEqual(other) +} + +func (s *Schema) IsEqual(other *Schema) bool { + if s == other { return true } - if s == nil || sch == nil { + if s == nil || other == nil { return false } - return reflect.DeepEqual(s.Field, sch.Field) + return reflect.DeepEqual(s.Field, other.Field) +} + +func IsEqual(s1, s2 *Schema) bool { + return s1.IsEqual(s2) } func (s Schema) WithIncludes(includes ...interface{}) *Schema { @@ -78,9 +88,9 @@ func (s Schema) SetMetadata(md map[string]string) *Schema { return &s } -func (f Schema) SetSingleLocale(r bool) *Schema { - f.SingleLocale = r - return &f +func (s Schema) SetSingleLocale(r bool) *Schema { + s.SingleLocale = r + return &s } func (s *Schema) ConvertTypes() error {