From 51b63f7ffb1d77e05f674d1af80a6edeb748494b Mon Sep 17 00:00:00 2001 From: Aaron Craelius Date: Fri, 23 Aug 2024 11:02:54 -0400 Subject: [PATCH] feat(schema): schema diffing (#21374) --- schema/diff/diff.go | 126 ++++++++++ schema/diff/diff_test.go | 334 +++++++++++++++++++++++++++ schema/diff/doc.go | 4 + schema/diff/enum_diff.go | 53 +++++ schema/diff/enum_diff_test.go | 69 ++++++ schema/diff/field_diff.go | 73 ++++++ schema/diff/field_diff_test.go | 63 +++++ schema/diff/object_type_diff.go | 146 ++++++++++++ schema/diff/object_type_diff_test.go | 269 +++++++++++++++++++++ 9 files changed, 1137 insertions(+) create mode 100644 schema/diff/diff.go create mode 100644 schema/diff/diff_test.go create mode 100644 schema/diff/doc.go create mode 100644 schema/diff/enum_diff.go create mode 100644 schema/diff/enum_diff_test.go create mode 100644 schema/diff/field_diff.go create mode 100644 schema/diff/field_diff_test.go create mode 100644 schema/diff/object_type_diff.go create mode 100644 schema/diff/object_type_diff_test.go diff --git a/schema/diff/diff.go b/schema/diff/diff.go new file mode 100644 index 0000000000..febb56c3e3 --- /dev/null +++ b/schema/diff/diff.go @@ -0,0 +1,126 @@ +package diff + +import "cosmossdk.io/schema" + +// ModuleSchemaDiff represents the difference between two module schemas. +type ModuleSchemaDiff struct { + // AddedObjectTypes is a list of object types that were added. + AddedObjectTypes []schema.ObjectType + + // ChangedObjectTypes is a list of object types that were changed. + ChangedObjectTypes []ObjectTypeDiff + + // RemovedObjectTypes is a list of object types that were removed. + RemovedObjectTypes []schema.ObjectType + + // AddedEnumTypes is a list of enum types that were added. + AddedEnumTypes []schema.EnumType + + // ChangedEnumTypes is a list of enum types that were changed. + ChangedEnumTypes []EnumTypeDiff + + // RemovedEnumTypes is a list of enum types that were removed. + RemovedEnumTypes []schema.EnumType +} + +// CompareModuleSchemas compares an old and a new module schemas and returns the difference between them. +// If the schemas are equivalent, the Empty method of the returned ModuleSchemaDiff will return true. +// +// Indexer implementations can use these diffs to perform automatic schema migration. +// The specific supported changes that a specific indexer supports are defined by that indexer implementation. +// However, as a general rule, it is suggested that indexers support the following changes to module schemas: +// - Adding object types +// - Adding enum types +// - Adding nullable value fields to object types +// - Adding enum values to enum types +// +// These changes are officially considered "compatible" changes, and the HasCompatibleChanges method of the returned +// ModuleSchemaDiff will return true if only compatible changes are present. +// Module authors can use the above guidelines as a reference point for what changes are generally +// considered safe to make to a module schema without breaking existing indexers. +func CompareModuleSchemas(oldSchema, newSchema schema.ModuleSchema) ModuleSchemaDiff { + diff := ModuleSchemaDiff{} + + oldSchema.ObjectTypes(func(oldObj schema.ObjectType) bool { + newTyp, found := newSchema.LookupType(oldObj.Name) + newObj, typeMatch := newTyp.(schema.ObjectType) + if !found || !typeMatch { + diff.RemovedObjectTypes = append(diff.RemovedObjectTypes, oldObj) + return true + } + objDiff := compareObjectType(oldObj, newObj) + if !objDiff.Empty() { + diff.ChangedObjectTypes = append(diff.ChangedObjectTypes, objDiff) + } + return true + }) + + newSchema.ObjectTypes(func(newObj schema.ObjectType) bool { + oldTyp, found := oldSchema.LookupType(newObj.TypeName()) + _, typeMatch := oldTyp.(schema.ObjectType) + if !found || !typeMatch { + diff.AddedObjectTypes = append(diff.AddedObjectTypes, newObj) + } + return true + }) + + oldSchema.EnumTypes(func(oldEnum schema.EnumType) bool { + newTyp, found := newSchema.LookupType(oldEnum.Name) + newEnum, typeMatch := newTyp.(schema.EnumType) + if !found || !typeMatch { + diff.RemovedEnumTypes = append(diff.RemovedEnumTypes, oldEnum) + return true + } + enumDiff := compareEnumType(oldEnum, newEnum) + if !enumDiff.Empty() { + diff.ChangedEnumTypes = append(diff.ChangedEnumTypes, enumDiff) + } + return true + }) + + newSchema.EnumTypes(func(newEnum schema.EnumType) bool { + oldTyp, found := oldSchema.LookupType(newEnum.TypeName()) + _, typeMatch := oldTyp.(schema.EnumType) + if !found || !typeMatch { + diff.AddedEnumTypes = append(diff.AddedEnumTypes, newEnum) + } + return true + }) + + return diff +} + +func (m ModuleSchemaDiff) Empty() bool { + return len(m.AddedObjectTypes) == 0 && + len(m.ChangedObjectTypes) == 0 && + len(m.RemovedObjectTypes) == 0 && + len(m.AddedEnumTypes) == 0 && + len(m.ChangedEnumTypes) == 0 && + len(m.RemovedEnumTypes) == 0 +} + +// HasCompatibleChanges returns true if the diff contains only compatible changes. +// Compatible changes are changes that are generally safe to make to a module schema without breaking existing indexers +// and indexers should aim to automatically migrate to such changes. +// See the CompareModuleSchemas function for a list of changes that are considered compatible. +func (m ModuleSchemaDiff) HasCompatibleChanges() bool { + // object and enum types can be added but not removed + // changed object and enum types must have compatible changes + if len(m.RemovedObjectTypes) != 0 || len(m.RemovedEnumTypes) != 0 { + return false + } + + for _, objectType := range m.ChangedObjectTypes { + if !objectType.HasCompatibleChanges() { + return false + } + } + + for _, enumType := range m.ChangedEnumTypes { + if !enumType.HasCompatibleChanges() { + return false + } + } + + return true +} diff --git a/schema/diff/diff_test.go b/schema/diff/diff_test.go new file mode 100644 index 0000000000..ac7563a5b6 --- /dev/null +++ b/schema/diff/diff_test.go @@ -0,0 +1,334 @@ +package diff + +import ( + "reflect" + "testing" + + "cosmossdk.io/schema" +) + +func TestCompareModuleSchemas(t *testing.T) { + tt := []struct { + name string + oldSchema schema.ModuleSchema + newSchema schema.ModuleSchema + diff ModuleSchemaDiff + hasCompatibleChanges bool + empty bool + }{ + { + name: "no change", + oldSchema: mustModuleSchema(t, schema.ObjectType{ + Name: "object1", + KeyFields: []schema.Field{{Name: "key1", Kind: schema.StringKind}}, + }), + newSchema: mustModuleSchema(t, schema.ObjectType{ + Name: "object1", + KeyFields: []schema.Field{{Name: "key1", Kind: schema.StringKind}}, + }), + diff: ModuleSchemaDiff{}, + hasCompatibleChanges: true, + empty: true, + }, + { + name: "object type added", + oldSchema: mustModuleSchema(t), + newSchema: mustModuleSchema(t, schema.ObjectType{ + Name: "object1", + KeyFields: []schema.Field{{Name: "key1", Kind: schema.StringKind}}, + }), + diff: ModuleSchemaDiff{ + AddedObjectTypes: []schema.ObjectType{ + { + Name: "object1", + KeyFields: []schema.Field{{Name: "key1", Kind: schema.StringKind}}, + }, + }, + }, + hasCompatibleChanges: true, + }, + { + name: "object type removed", + oldSchema: mustModuleSchema(t, schema.ObjectType{ + Name: "object1", + KeyFields: []schema.Field{{Name: "key1", Kind: schema.StringKind}}, + }), + newSchema: mustModuleSchema(t), + diff: ModuleSchemaDiff{ + RemovedObjectTypes: []schema.ObjectType{ + { + Name: "object1", + KeyFields: []schema.Field{{Name: "key1", Kind: schema.StringKind}}, + }, + }, + }, + hasCompatibleChanges: false, + }, + { + name: "object type changed, key field added", + oldSchema: mustModuleSchema(t, schema.ObjectType{ + Name: "object1", + KeyFields: []schema.Field{{Name: "key1", Kind: schema.StringKind}}, + }), + newSchema: mustModuleSchema(t, schema.ObjectType{ + Name: "object1", + KeyFields: []schema.Field{{Name: "key1", Kind: schema.StringKind}, {Name: "key2", Kind: schema.StringKind}}, + }), + diff: ModuleSchemaDiff{ + ChangedObjectTypes: []ObjectTypeDiff{ + { + Name: "object1", + KeyFieldsDiff: FieldsDiff{ + Added: []schema.Field{ + {Name: "key2", Kind: schema.StringKind}, + }, + }, + }, + }, + }, + hasCompatibleChanges: false, + }, + { + name: "object type changed, nullable value field added", + oldSchema: mustModuleSchema(t, schema.ObjectType{ + Name: "object1", + KeyFields: []schema.Field{{Name: "key1", Kind: schema.StringKind}}, + }), + newSchema: mustModuleSchema(t, schema.ObjectType{ + Name: "object1", + KeyFields: []schema.Field{{Name: "key1", Kind: schema.StringKind}}, + ValueFields: []schema.Field{{Name: "value1", Kind: schema.StringKind, Nullable: true}}, + }), + diff: ModuleSchemaDiff{ + ChangedObjectTypes: []ObjectTypeDiff{ + { + Name: "object1", + ValueFieldsDiff: FieldsDiff{ + Added: []schema.Field{{Name: "value1", Kind: schema.StringKind, Nullable: true}}, + }, + }, + }, + }, + hasCompatibleChanges: true, + }, + { + name: "object type changed, non-nullable value field added", + oldSchema: mustModuleSchema(t, schema.ObjectType{ + Name: "object1", + KeyFields: []schema.Field{{Name: "key1", Kind: schema.StringKind}}, + }), + newSchema: mustModuleSchema(t, schema.ObjectType{ + Name: "object1", + KeyFields: []schema.Field{{Name: "key1", Kind: schema.StringKind}}, + ValueFields: []schema.Field{{Name: "value1", Kind: schema.StringKind}}, + }), + diff: ModuleSchemaDiff{ + ChangedObjectTypes: []ObjectTypeDiff{ + { + Name: "object1", + ValueFieldsDiff: FieldsDiff{ + Added: []schema.Field{{Name: "value1", Kind: schema.StringKind}}, + }, + }, + }, + }, + hasCompatibleChanges: false, + }, + { + name: "object type changed, fields reordered", + oldSchema: mustModuleSchema(t, schema.ObjectType{ + Name: "object1", + KeyFields: []schema.Field{{Name: "key1", Kind: schema.StringKind}, {Name: "key2", Kind: schema.StringKind}}, + }), + newSchema: mustModuleSchema(t, schema.ObjectType{ + Name: "object1", + KeyFields: []schema.Field{{Name: "key2", Kind: schema.StringKind}, {Name: "key1", Kind: schema.StringKind}}, + }), + diff: ModuleSchemaDiff{ + ChangedObjectTypes: []ObjectTypeDiff{ + { + Name: "object1", + KeyFieldsDiff: FieldsDiff{ + OldOrder: []string{"key1", "key2"}, + NewOrder: []string{"key2", "key1"}, + }, + }, + }, + }, + hasCompatibleChanges: false, + }, + { + name: "enum type added, nullable value field added", + oldSchema: mustModuleSchema(t, schema.ObjectType{ + Name: "object1", + KeyFields: []schema.Field{{Name: "key1", Kind: schema.Int32Kind}}, + }), + newSchema: mustModuleSchema(t, schema.ObjectType{ + Name: "object1", + KeyFields: []schema.Field{{Name: "key1", Kind: schema.Int32Kind}}, + ValueFields: []schema.Field{ + { + Name: "value1", + Kind: schema.EnumKind, + EnumType: schema.EnumType{Name: "enum1", Values: []string{"a", "b"}}, + Nullable: true, + }, + }, + }), + diff: ModuleSchemaDiff{ + ChangedObjectTypes: []ObjectTypeDiff{ + { + Name: "object1", + ValueFieldsDiff: FieldsDiff{ + Added: []schema.Field{ + { + Name: "value1", + Kind: schema.EnumKind, + EnumType: schema.EnumType{Name: "enum1", Values: []string{"a", "b"}}, + Nullable: true, + }, + }, + }, + }, + }, + AddedEnumTypes: []schema.EnumType{ + {Name: "enum1", Values: []string{"a", "b"}}, + }, + }, + hasCompatibleChanges: true, + }, + { + name: "enum type removed", + oldSchema: mustModuleSchema(t, schema.ObjectType{ + Name: "object1", + KeyFields: []schema.Field{{Name: "key1", Kind: schema.Int32Kind}}, + ValueFields: []schema.Field{ + { + Name: "value1", + Kind: schema.EnumKind, + EnumType: schema.EnumType{Name: "enum1", Values: []string{"a", "b"}}, + }, + }, + }), + newSchema: mustModuleSchema(t, schema.ObjectType{ + Name: "object1", + KeyFields: []schema.Field{{Name: "key1", Kind: schema.Int32Kind}}, + }), + diff: ModuleSchemaDiff{ + ChangedObjectTypes: []ObjectTypeDiff{ + { + Name: "object1", + ValueFieldsDiff: FieldsDiff{ + Removed: []schema.Field{ + { + Name: "value1", + Kind: schema.EnumKind, + EnumType: schema.EnumType{Name: "enum1", Values: []string{"a", "b"}}, + }, + }, + }, + }, + }, + RemovedEnumTypes: []schema.EnumType{ + {Name: "enum1", Values: []string{"a", "b"}}, + }, + }, + hasCompatibleChanges: false, + }, + { + name: "enum value added", + oldSchema: mustModuleSchema(t, schema.ObjectType{ + Name: "object1", + KeyFields: []schema.Field{{Name: "key1", Kind: schema.EnumKind, EnumType: schema.EnumType{Name: "enum1", Values: []string{"a"}}}}, + }), + newSchema: mustModuleSchema(t, schema.ObjectType{ + Name: "object1", + KeyFields: []schema.Field{{Name: "key1", Kind: schema.EnumKind, EnumType: schema.EnumType{Name: "enum1", Values: []string{"a", "b"}}}}, + }), + diff: ModuleSchemaDiff{ + ChangedEnumTypes: []EnumTypeDiff{ + { + Name: "enum1", + AddedValues: []string{"b"}, + }, + }, + }, + hasCompatibleChanges: true, + }, + { + name: "enum value removed", + oldSchema: mustModuleSchema(t, schema.ObjectType{ + Name: "object1", + KeyFields: []schema.Field{{Name: "key1", Kind: schema.EnumKind, EnumType: schema.EnumType{Name: "enum1", Values: []string{"a", "b", "c"}}}}, + }), + newSchema: mustModuleSchema(t, schema.ObjectType{ + Name: "object1", + KeyFields: []schema.Field{{Name: "key1", Kind: schema.EnumKind, EnumType: schema.EnumType{Name: "enum1", Values: []string{"a", "b"}}}}, + }), + diff: ModuleSchemaDiff{ + ChangedEnumTypes: []EnumTypeDiff{ + { + Name: "enum1", + RemovedValues: []string{"c"}, + }, + }, + }, + hasCompatibleChanges: false, + }, + { + name: "object type and enum type name switched", + oldSchema: mustModuleSchema(t, schema.ObjectType{ + Name: "foo", + KeyFields: []schema.Field{{Name: "key1", Kind: schema.EnumKind, EnumType: schema.EnumType{Name: "bar", Values: []string{"a"}}}}, + }), + newSchema: mustModuleSchema(t, schema.ObjectType{ + Name: "bar", + KeyFields: []schema.Field{{Name: "key1", Kind: schema.EnumKind, EnumType: schema.EnumType{Name: "foo", Values: []string{"a"}}}}, + }), + diff: ModuleSchemaDiff{ + RemovedObjectTypes: []schema.ObjectType{ + { + Name: "foo", + KeyFields: []schema.Field{{Name: "key1", Kind: schema.EnumKind, EnumType: schema.EnumType{Name: "bar", Values: []string{"a"}}}}, + }}, + AddedObjectTypes: []schema.ObjectType{ + { + Name: "bar", + KeyFields: []schema.Field{{Name: "key1", Kind: schema.EnumKind, EnumType: schema.EnumType{Name: "foo", Values: []string{"a"}}}}, + }, + }, + RemovedEnumTypes: []schema.EnumType{ + {Name: "bar", Values: []string{"a"}}, + }, + AddedEnumTypes: []schema.EnumType{ + {Name: "foo", Values: []string{"a"}}, + }, + }, + hasCompatibleChanges: false, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + got := CompareModuleSchemas(tc.oldSchema, tc.newSchema) + if !reflect.DeepEqual(got, tc.diff) { + t.Errorf("CompareModuleSchemas() = %v, want %v", got, tc.diff) + } + hasCompatibleChanges := got.HasCompatibleChanges() + if hasCompatibleChanges != tc.hasCompatibleChanges { + t.Errorf("HasCompatibleChanges() = %v, want %v", hasCompatibleChanges, tc.hasCompatibleChanges) + } + if tc.empty != got.Empty() { + t.Errorf("Empty() = %v, want %v", got.Empty(), tc.empty) + } + }) + } +} + +func mustModuleSchema(t *testing.T, objectTypes ...schema.ObjectType) schema.ModuleSchema { + s, err := schema.NewModuleSchema(objectTypes) + if err != nil { + t.Fatal(err) + } + return s +} diff --git a/schema/diff/doc.go b/schema/diff/doc.go new file mode 100644 index 0000000000..54b0d5e87f --- /dev/null +++ b/schema/diff/doc.go @@ -0,0 +1,4 @@ +// Package diff provides the CompareModuleSchemas function which compares module schemas +// and their types and generates a structured diff. This diff can be used to check +// compatibility between different versions of a module schema. +package diff diff --git a/schema/diff/enum_diff.go b/schema/diff/enum_diff.go new file mode 100644 index 0000000000..fac0907070 --- /dev/null +++ b/schema/diff/enum_diff.go @@ -0,0 +1,53 @@ +package diff + +import "cosmossdk.io/schema" + +// EnumTypeDiff represents the difference between two enum types. +type EnumTypeDiff struct { + // Name is the name of the enum type. + Name string + + // AddedValues is a list of values that were added. + AddedValues []string + + // RemovedValues is a list of values that were removed. + RemovedValues []string +} + +func compareEnumType(oldEnum, newEnum schema.EnumType) EnumTypeDiff { + diff := EnumTypeDiff{ + Name: oldEnum.TypeName(), + } + + newValues := make(map[string]struct{}) + for _, v := range newEnum.Values { + newValues[v] = struct{}{} + } + + oldValues := make(map[string]struct{}) + for _, v := range oldEnum.Values { + oldValues[v] = struct{}{} + if _, ok := newValues[v]; !ok { + diff.RemovedValues = append(diff.RemovedValues, v) + } + } + + for _, v := range newEnum.Values { + if _, ok := oldValues[v]; !ok { + diff.AddedValues = append(diff.AddedValues, v) + } + } + + return diff +} + +// Empty returns true if the enum type diff has no changes. +func (e EnumTypeDiff) Empty() bool { + return len(e.AddedValues) == 0 && len(e.RemovedValues) == 0 +} + +// HasCompatibleChanges returns true if the diff contains only compatible changes. +// The only supported compatible change is adding values. +func (e EnumTypeDiff) HasCompatibleChanges() bool { + return len(e.RemovedValues) == 0 +} diff --git a/schema/diff/enum_diff_test.go b/schema/diff/enum_diff_test.go new file mode 100644 index 0000000000..0478875762 --- /dev/null +++ b/schema/diff/enum_diff_test.go @@ -0,0 +1,69 @@ +package diff + +import ( + "reflect" + "testing" + + "cosmossdk.io/schema" +) + +func Test_compareEnumType(t *testing.T) { + tt := []struct { + name string + oldEnum schema.EnumType + newEnum schema.EnumType + diff EnumTypeDiff + hasCompatibleChanges bool + }{ + { + name: "no change", + oldEnum: schema.EnumType{ + Values: []string{"a", "b"}, + }, + newEnum: schema.EnumType{ + Values: []string{"a", "b"}, + }, + diff: EnumTypeDiff{}, + hasCompatibleChanges: true, + }, + { + name: "value added", + oldEnum: schema.EnumType{ + Values: []string{"a"}, + }, + newEnum: schema.EnumType{ + Values: []string{"a", "b"}, + }, + diff: EnumTypeDiff{ + AddedValues: []string{"b"}, + }, + hasCompatibleChanges: true, + }, + { + name: "value removed", + oldEnum: schema.EnumType{ + Values: []string{"a", "b"}, + }, + newEnum: schema.EnumType{ + Values: []string{"a"}, + }, + diff: EnumTypeDiff{ + RemovedValues: []string{"b"}, + }, + hasCompatibleChanges: false, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + got := compareEnumType(tc.oldEnum, tc.newEnum) + if !reflect.DeepEqual(got, tc.diff) { + t.Errorf("compareEnumType() = %v, want %v", got, tc.diff) + } + hasCompatibleChanges := got.HasCompatibleChanges() + if hasCompatibleChanges != tc.hasCompatibleChanges { + t.Errorf("HasCompatibleChanges() = %v, want %v", hasCompatibleChanges, tc.hasCompatibleChanges) + } + }) + } +} diff --git a/schema/diff/field_diff.go b/schema/diff/field_diff.go new file mode 100644 index 0000000000..76edd4db0c --- /dev/null +++ b/schema/diff/field_diff.go @@ -0,0 +1,73 @@ +package diff + +import "cosmossdk.io/schema" + +// FieldDiff represents the difference between two fields. +// The KindChanged, NullableChanged, and EnumTypeChanged methods can be used to determine +// what specific changes were made to the field. +type FieldDiff struct { + // Name is the name of the field. + Name string + + // OldKind is the old kind of the field. It will be InvalidKind if there was no change. + OldKind schema.Kind + + // NewKind is the new kind of the field. It will be InvalidKind if there was no change. + NewKind schema.Kind + + // OldNullable is the old nullable property of the field. + OldNullable bool + + // NewNullable is the new nullable property of the field. + NewNullable bool + + // OldEnumType is the name of the old enum type of the field. + // It will be empty if the field is not an enum type or if there was no change. + OldEnumType string + + // NewEnumType is the name of the new enum type of the field. + // It will be empty if the field is not an enum type or if there was no change. + NewEnumType string +} + +func compareField(oldField, newField schema.Field) FieldDiff { + diff := FieldDiff{ + Name: oldField.Name, + } + if oldField.Kind != newField.Kind { + diff.OldKind = oldField.Kind + diff.NewKind = newField.Kind + } + + diff.OldNullable = oldField.Nullable + diff.NewNullable = newField.Nullable + + if oldField.EnumType.Name != newField.EnumType.Name { + diff.OldEnumType = oldField.EnumType.Name + diff.NewEnumType = newField.EnumType.Name + } + return diff +} + +// Empty returns true if the field diff has no changes. +func (d FieldDiff) Empty() bool { + return !d.KindChanged() && !d.NullableChanged() && !d.EnumTypeChanged() +} + +// KindChanged returns true if the field kind changed. +func (d FieldDiff) KindChanged() bool { + return d.OldKind != d.NewKind +} + +// NullableChanged returns true if the field nullable property changed. +func (d FieldDiff) NullableChanged() bool { + return d.OldNullable != d.NewNullable +} + +// EnumTypeChanged returns true if the field enum type changed. +// Note that if the enum type name remained the same but the values of +// the enum type changed, that won't be reported here but rather in the +// ModuleSchemaDiff's ChangedEnumTypes field. +func (d FieldDiff) EnumTypeChanged() bool { + return d.OldEnumType != d.NewEnumType +} diff --git a/schema/diff/field_diff_test.go b/schema/diff/field_diff_test.go new file mode 100644 index 0000000000..cdfbb74de9 --- /dev/null +++ b/schema/diff/field_diff_test.go @@ -0,0 +1,63 @@ +package diff + +import ( + "fmt" + "reflect" + "testing" + + "cosmossdk.io/schema" +) + +func Test_compareField(t *testing.T) { + tests := []struct { + oldField schema.Field + newField schema.Field + wantDiff FieldDiff + trueF func(FieldDiff) bool + }{ + { + oldField: schema.Field{Kind: schema.Int32Kind}, + newField: schema.Field{Kind: schema.Int32Kind}, + wantDiff: FieldDiff{}, + trueF: FieldDiff.Empty, + }, + { + oldField: schema.Field{Kind: schema.StringKind}, + newField: schema.Field{Kind: schema.Int32Kind}, + wantDiff: FieldDiff{ + OldKind: schema.StringKind, + NewKind: schema.Int32Kind, + }, + trueF: FieldDiff.KindChanged, + }, + { + oldField: schema.Field{Kind: schema.StringKind}, + newField: schema.Field{Kind: schema.StringKind, Nullable: true}, + wantDiff: FieldDiff{ + NewNullable: true, + }, + trueF: FieldDiff.NullableChanged, + }, + { + oldField: schema.Field{Kind: schema.EnumKind, EnumType: schema.EnumType{Name: "old"}}, + newField: schema.Field{Kind: schema.EnumKind, EnumType: schema.EnumType{Name: "new"}}, + wantDiff: FieldDiff{ + OldEnumType: "old", + NewEnumType: "new", + }, + trueF: FieldDiff.EnumTypeChanged, + }, + } + + for i, tt := range tests { + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + gotDiff := compareField(tt.oldField, tt.newField) + if !reflect.DeepEqual(gotDiff, tt.wantDiff) { + t.Errorf("compareField() = %v, want %v", gotDiff, tt.wantDiff) + } + if tt.trueF != nil && !tt.trueF(gotDiff) { + t.Errorf("trueF() = false, want true") + } + }) + } +} diff --git a/schema/diff/object_type_diff.go b/schema/diff/object_type_diff.go new file mode 100644 index 0000000000..9f169a6df9 --- /dev/null +++ b/schema/diff/object_type_diff.go @@ -0,0 +1,146 @@ +package diff + +import "cosmossdk.io/schema" + +// ObjectTypeDiff represents the difference between two object types. +// The Empty method of KeyFieldsDiff and ValueFieldsDiff can be used to determine +// if there were any changes to the key fields or value fields. +type ObjectTypeDiff struct { + // Name is the name of the object type. + Name string + + // KeyFieldsDiff is the difference between the key fields of the object type. + KeyFieldsDiff FieldsDiff + + // ValueFieldsDiff is the difference between the value fields of the object type. + ValueFieldsDiff FieldsDiff +} + +// FieldsDiff represents the difference between two lists of fields. +// Fields will be compared based on name first, and then if there is any +// difference in ordering that will be reported in OldOrder and NewOrder. +// If there is any order change, the OrderChanged method will return true. +// If fields were only added or removed but the order otherwise didn't change, +// then the OldOrder and NewOrder will still be empty. +type FieldsDiff struct { + // Added is a list of fields that were added. + Added []schema.Field + + // Changed is a list of fields that were changed. + Changed []FieldDiff + + // Removed is a list of fields that were removed. + Removed []schema.Field + + // OldOrder is the order of fields in the old list. It will be empty if the order has not changed. + OldOrder []string + + // NewOrder is the order of fields in the new list. It will be empty if the order has not changed. + NewOrder []string +} + +func compareObjectType(oldObj, newObj schema.ObjectType) ObjectTypeDiff { + diff := ObjectTypeDiff{ + Name: oldObj.TypeName(), + } + + diff.KeyFieldsDiff = compareFields(oldObj.KeyFields, newObj.KeyFields) + diff.ValueFieldsDiff = compareFields(oldObj.ValueFields, newObj.ValueFields) + + return diff +} + +func compareFields(oldFields, newFields []schema.Field) FieldsDiff { + diff := FieldsDiff{} + + newFieldMap := make(map[string]schema.Field) + for _, f := range newFields { + newFieldMap[f.Name] = f + } + + oldFieldMap := make(map[string]schema.Field) + for _, oldField := range oldFields { + oldFieldMap[oldField.Name] = oldField + newField, ok := newFieldMap[oldField.Name] + if !ok { + diff.Removed = append(diff.Removed, oldField) + } else { + fieldDiff := compareField(oldField, newField) + if !fieldDiff.Empty() { + diff.Changed = append(diff.Changed, fieldDiff) + } + } + } + + for _, newField := range newFields { + if _, ok := oldFieldMap[newField.Name]; !ok { + diff.Added = append(diff.Added, newField) + } + } + + oldOrder := make([]string, 0, len(oldFields)) + for _, f := range oldFields { + oldOrder = append(oldOrder, f.Name) + } + + orderChanged := false + newOrder := make([]string, 0, len(newFields)) + for i, f := range newFields { + newOrder = append(newOrder, f.Name) + if i < len(oldOrder) && f.Name != oldOrder[i] { + orderChanged = true + } + } + + if orderChanged { + diff.OldOrder = oldOrder + diff.NewOrder = newOrder + } + + return diff +} + +// Empty returns true if the object type diff has no changes. +func (o ObjectTypeDiff) Empty() bool { + return o.KeyFieldsDiff.Empty() && o.ValueFieldsDiff.Empty() +} + +// HasCompatibleChanges returns true if the diff contains only compatible changes. +// The only supported compatible change is adding nullable value fields. +func (o ObjectTypeDiff) HasCompatibleChanges() bool { + if !o.KeyFieldsDiff.Empty() { + return false + } + + if len(o.ValueFieldsDiff.Changed) != 0 || + len(o.ValueFieldsDiff.Removed) != 0 || + o.ValueFieldsDiff.OrderChanged() { + return false + } + + for _, field := range o.ValueFieldsDiff.Added { + if !field.Nullable { + return false + } + } + + return true +} + +// Empty returns true if the field diff has no changes. +func (d FieldsDiff) Empty() bool { + if len(d.Added) != 0 || len(d.Changed) != 0 || len(d.Removed) != 0 { + return false + } + + return !d.OrderChanged() +} + +// OrderChanged returns true if the field order changed. +func (d FieldsDiff) OrderChanged() bool { + if len(d.OldOrder) == 0 && len(d.NewOrder) == 0 { + return false + } + + return true +} diff --git a/schema/diff/object_type_diff_test.go b/schema/diff/object_type_diff_test.go new file mode 100644 index 0000000000..b29f8326c6 --- /dev/null +++ b/schema/diff/object_type_diff_test.go @@ -0,0 +1,269 @@ +package diff + +import ( + "reflect" + "testing" + + "cosmossdk.io/schema" +) + +func Test_objectTypeDiff(t *testing.T) { + tt := []struct { + name string + oldType schema.ObjectType + newType schema.ObjectType + diff ObjectTypeDiff + trueF func(ObjectTypeDiff) bool + hasCompatibleChanges bool + }{ + { + name: "no change", + oldType: schema.ObjectType{ + KeyFields: []schema.Field{{Name: "id", Kind: schema.Int32Kind}}, + }, + newType: schema.ObjectType{ + KeyFields: []schema.Field{{Name: "id", Kind: schema.Int32Kind}}, + }, + diff: ObjectTypeDiff{}, + trueF: ObjectTypeDiff.Empty, + hasCompatibleChanges: true, + }, + { + name: "key fields changed", + oldType: schema.ObjectType{ + KeyFields: []schema.Field{{Name: "id", Kind: schema.Int32Kind}}, + }, + newType: schema.ObjectType{ + KeyFields: []schema.Field{{Name: "id", Kind: schema.StringKind}}, + }, + diff: ObjectTypeDiff{ + KeyFieldsDiff: FieldsDiff{ + Changed: []FieldDiff{ + { + Name: "id", + OldKind: schema.Int32Kind, + NewKind: schema.StringKind, + }, + }, + }, + }, + trueF: func(d ObjectTypeDiff) bool { return !d.KeyFieldsDiff.Empty() }, + hasCompatibleChanges: false, + }, + { + name: "value fields changed", + oldType: schema.ObjectType{ + ValueFields: []schema.Field{{Name: "name", Kind: schema.StringKind}}, + }, + newType: schema.ObjectType{ + ValueFields: []schema.Field{{Name: "name", Kind: schema.Int32Kind}}, + }, + diff: ObjectTypeDiff{ + ValueFieldsDiff: FieldsDiff{ + Changed: []FieldDiff{ + { + Name: "name", + OldKind: schema.StringKind, + NewKind: schema.Int32Kind, + }, + }, + }, + }, + trueF: func(d ObjectTypeDiff) bool { return !d.ValueFieldsDiff.Empty() }, + hasCompatibleChanges: false, + }, + { + name: "nullable value field added", + oldType: schema.ObjectType{ + ValueFields: []schema.Field{{Name: "id", Kind: schema.Int32Kind}}, + }, + newType: schema.ObjectType{ + ValueFields: []schema.Field{{Name: "id", Kind: schema.Int32Kind}, {Name: "name", Kind: schema.StringKind, Nullable: true}}, + }, + diff: ObjectTypeDiff{ + ValueFieldsDiff: FieldsDiff{ + Added: []schema.Field{{Name: "name", Kind: schema.StringKind, Nullable: true}}, + }, + }, + trueF: func(d ObjectTypeDiff) bool { return !d.ValueFieldsDiff.Empty() }, + hasCompatibleChanges: true, + }, + { + name: "non-nullable value field added", + oldType: schema.ObjectType{ + ValueFields: []schema.Field{{Name: "id", Kind: schema.Int32Kind}}, + }, + newType: schema.ObjectType{ + ValueFields: []schema.Field{{Name: "id", Kind: schema.Int32Kind}, {Name: "name", Kind: schema.StringKind}}, + }, + diff: ObjectTypeDiff{ + ValueFieldsDiff: FieldsDiff{ + Added: []schema.Field{{Name: "name", Kind: schema.StringKind}}, + }, + }, + trueF: func(d ObjectTypeDiff) bool { return !d.ValueFieldsDiff.Empty() }, + hasCompatibleChanges: false, + }, + { + name: "fields reordered", + oldType: schema.ObjectType{ + KeyFields: []schema.Field{{Name: "id", Kind: schema.Int32Kind}, {Name: "name", Kind: schema.StringKind}}, + ValueFields: []schema.Field{{Name: "x", Kind: schema.Int32Kind}, {Name: "y", Kind: schema.StringKind}}, + }, + newType: schema.ObjectType{ + KeyFields: []schema.Field{{Name: "name", Kind: schema.StringKind}, {Name: "id", Kind: schema.Int32Kind}}, + ValueFields: []schema.Field{{Name: "y", Kind: schema.StringKind}, {Name: "x", Kind: schema.Int32Kind}}, + }, + diff: ObjectTypeDiff{ + KeyFieldsDiff: FieldsDiff{ + OldOrder: []string{"id", "name"}, + NewOrder: []string{"name", "id"}, + }, + ValueFieldsDiff: FieldsDiff{ + OldOrder: []string{"x", "y"}, + NewOrder: []string{"y", "x"}, + }, + }, + trueF: func(d ObjectTypeDiff) bool { return !d.KeyFieldsDiff.Empty() && !d.ValueFieldsDiff.Empty() }, + hasCompatibleChanges: false, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + got := compareObjectType(tc.oldType, tc.newType) + if !reflect.DeepEqual(got, tc.diff) { + t.Errorf("compareObjectType() = %v, want %v", got, tc.diff) + } + hasCompatibleChanges := got.HasCompatibleChanges() + if hasCompatibleChanges != tc.hasCompatibleChanges { + t.Errorf("HasCompatibleChanges() = %v, want %v", hasCompatibleChanges, tc.hasCompatibleChanges) + } + }) + } +} + +func Test_fieldsDiff(t *testing.T) { + tt := []struct { + name string + oldFields []schema.Field + newFields []schema.Field + diff FieldsDiff + }{ + { + name: "no change", + oldFields: []schema.Field{{Name: "id", Kind: schema.Int32Kind}}, + newFields: []schema.Field{{Name: "id", Kind: schema.Int32Kind}}, + }, + { + name: "field added", + oldFields: []schema.Field{{Name: "id", Kind: schema.Int32Kind}}, + newFields: []schema.Field{{Name: "id", Kind: schema.Int32Kind}, {Name: "name", Kind: schema.StringKind}}, + diff: FieldsDiff{ + Added: []schema.Field{{Name: "name", Kind: schema.StringKind}}, + }, + }, + { + name: "field removed", + oldFields: []schema.Field{{Name: "id", Kind: schema.Int32Kind}, {Name: "name", Kind: schema.StringKind}}, + newFields: []schema.Field{{Name: "id", Kind: schema.Int32Kind}}, + diff: FieldsDiff{ + Removed: []schema.Field{{Name: "name", Kind: schema.StringKind}}, + }, + }, + { + name: "field changed", + oldFields: []schema.Field{{Name: "id", Kind: schema.Int32Kind}, {Name: "name", Kind: schema.StringKind}}, + newFields: []schema.Field{{Name: "id", Kind: schema.Int32Kind}, {Name: "name", Kind: schema.Int32Kind}}, + diff: FieldsDiff{ + Changed: []FieldDiff{ + { + Name: "name", + OldKind: schema.StringKind, + NewKind: schema.Int32Kind, + }, + }, + }, + }, + { + name: "field order changed", + oldFields: []schema.Field{{Name: "id", Kind: schema.Int32Kind}, {Name: "name", Kind: schema.StringKind}}, + newFields: []schema.Field{{Name: "name", Kind: schema.StringKind}, {Name: "id", Kind: schema.Int32Kind}}, + diff: FieldsDiff{ + OldOrder: []string{"id", "name"}, + NewOrder: []string{"name", "id"}, + }, + }, + { + name: "field order changed with added fields", + oldFields: []schema.Field{{Name: "id", Kind: schema.Int32Kind}}, + newFields: []schema.Field{{Name: "name", Kind: schema.StringKind}, {Name: "id", Kind: schema.Int32Kind}}, + diff: FieldsDiff{ + Added: []schema.Field{{Name: "name", Kind: schema.StringKind}}, + OldOrder: []string{"id"}, + NewOrder: []string{"name", "id"}, + }, + }, + { + name: "field order changed with removed fields", + oldFields: []schema.Field{{Name: "name", Kind: schema.StringKind}, {Name: "id", Kind: schema.Int32Kind}}, + newFields: []schema.Field{{Name: "id", Kind: schema.Int32Kind}}, + diff: FieldsDiff{ + Removed: []schema.Field{{Name: "name", Kind: schema.StringKind}}, + OldOrder: []string{"name", "id"}, + NewOrder: []string{"id"}, + }, + }, + { + name: "field order changed with changed fields", + oldFields: []schema.Field{{Name: "name", Kind: schema.StringKind}, {Name: "id", Kind: schema.Int32Kind}}, + newFields: []schema.Field{{Name: "id", Kind: schema.Int32Kind}, {Name: "name", Kind: schema.Int32Kind}}, + diff: FieldsDiff{ + Changed: []FieldDiff{ + { + Name: "name", + OldKind: schema.StringKind, + NewKind: schema.Int32Kind, + }, + }, + OldOrder: []string{"name", "id"}, + NewOrder: []string{"id", "name"}, + }, + }, + { + name: "added, removed, changed and reordered fields", + oldFields: []schema.Field{ + {Name: "id", Kind: schema.Int32Kind}, + {Name: "name", Kind: schema.StringKind}, + {Name: "age", Kind: schema.Int32Kind}, + }, + newFields: []schema.Field{ + {Name: "name", Kind: schema.Int32Kind}, + {Name: "age", Kind: schema.Int32Kind}, + {Name: "email", Kind: schema.StringKind}, + }, + diff: FieldsDiff{ + Added: []schema.Field{{Name: "email", Kind: schema.StringKind}}, + Removed: []schema.Field{{Name: "id", Kind: schema.Int32Kind}}, + Changed: []FieldDiff{ + { + Name: "name", + OldKind: schema.StringKind, + NewKind: schema.Int32Kind, + }, + }, + OldOrder: []string{"id", "name", "age"}, + NewOrder: []string{"name", "age", "email"}, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + got := compareFields(tc.oldFields, tc.newFields) + if !reflect.DeepEqual(got, tc.diff) { + t.Errorf("compareFields() = %v, want %v", got, tc.diff) + } + }) + } +}