From 89ec787f7ef24c55c028640840b72f7d48dda90d Mon Sep 17 00:00:00 2001 From: Aaron Craelius Date: Wed, 28 Aug 2024 14:42:40 -0400 Subject: [PATCH] feat(schema): add JSON marshaling for ModuleSchema (#21371) --- schema/enum.go | 10 ++-- schema/field.go | 12 ++-- schema/field_test.go | 60 +++++++++++++++++++ schema/kind.go | 31 ++++++++++ schema/kind_test.go | 55 +++++++++++++++++ schema/module_schema.go | 56 +++++++++++++++++ schema/module_schema_test.go | 24 ++++++++ schema/object_type.go | 8 +-- .../testdata/app_sim_example_schema.txt | 4 +- schema/testing/json_test.go | 23 +++++++ 10 files changed, 267 insertions(+), 16 deletions(-) create mode 100644 schema/testing/json_test.go diff --git a/schema/enum.go b/schema/enum.go index f39a3b7155bd..942758033de8 100644 --- a/schema/enum.go +++ b/schema/enum.go @@ -12,16 +12,16 @@ type EnumType struct { // Its name must be unique between all enum types and object types in the module. // The same enum, however, can be used in multiple object types and fields as long as the // definition is identical each time. - Name string + Name string `json:"name,omitempty"` // Values is a list of distinct, non-empty values that are part of the enum type. // Each value must conform to the NameFormat regular expression. - Values []EnumValueDefinition + Values []EnumValueDefinition `json:"values"` // NumericKind is the numeric kind used to represent the enum values numerically. // If it is left empty, Int32Kind is used by default. // Valid values are Uint8Kind, Int8Kind, Uint16Kind, Int16Kind, and Int32Kind. - NumericKind Kind + NumericKind Kind `json:"numeric_kind,omitempty"` } // EnumValueDefinition represents a value in an enum type. @@ -29,11 +29,11 @@ type EnumValueDefinition struct { // Name is the name of the enum value. // It must conform to the NameFormat regular expression. // Its name must be unique between all values in the enum. - Name string + Name string `json:"name"` // Value is the numeric value of the enum. // It must be unique between all values in the enum. - Value int32 + Value int32 `json:"value"` } // TypeName implements the Type interface. diff --git a/schema/field.go b/schema/field.go index 4a95ee98bdbb..df6b6139cd12 100644 --- a/schema/field.go +++ b/schema/field.go @@ -1,20 +1,22 @@ package schema -import "fmt" +import ( + "fmt" +) // Field represents a field in an object type. type Field struct { // Name is the name of the field. It must conform to the NameFormat regular expression. - Name string + Name string `json:"name"` // Kind is the basic type of the field. - Kind Kind + Kind Kind `json:"kind"` // Nullable indicates whether null values are accepted for the field. Key fields CANNOT be nullable. - Nullable bool + Nullable bool `json:"nullable,omitempty"` // ReferencedType is the referenced type name when Kind is EnumKind. - ReferencedType string + ReferencedType string `json:"referenced_type,omitempty"` } // Validate validates the field. diff --git a/schema/field_test.go b/schema/field_test.go index 32264dbce3fb..d8873c5b9fb2 100644 --- a/schema/field_test.go +++ b/schema/field_test.go @@ -1,6 +1,8 @@ package schema import ( + "encoding/json" + "reflect" "strings" "testing" ) @@ -165,6 +167,64 @@ func TestField_ValidateValue(t *testing.T) { } } +func TestFieldJSON(t *testing.T) { + tt := []struct { + field Field + json string + expectErr bool + }{ + { + field: Field{ + Name: "field1", + Kind: StringKind, + }, + json: `{"name":"field1","kind":"string"}`, + }, + { + field: Field{ + Name: "field1", + Kind: Int32Kind, + Nullable: true, + }, + json: `{"name":"field1","kind":"int32","nullable":true}`, + }, + { + field: Field{ + Name: "field1", + Kind: EnumKind, + ReferencedType: "enum", + }, + json: `{"name":"field1","kind":"enum","referenced_type":"enum"}`, + }, + } + + for _, tc := range tt { + t.Run(tc.json, func(t *testing.T) { + b, err := json.Marshal(tc.field) + if tc.expectErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + } else { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(b) != tc.json { + t.Fatalf("expected %s, got %s", tc.json, string(b)) + } + var field Field + err = json.Unmarshal(b, &field) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !reflect.DeepEqual(field, tc.field) { + t.Fatalf("expected %v, got %v", tc.field, field) + } + } + }) + } +} + var testEnumSchema = MustNewModuleSchema(EnumType{ Name: "enum", Values: []EnumValueDefinition{{Name: "a", Value: 1}, {Name: "b", Value: 2}}, diff --git a/schema/kind.go b/schema/kind.go index 1aec2b62f407..96f9a934842e 100644 --- a/schema/kind.go +++ b/schema/kind.go @@ -431,3 +431,34 @@ func KindForGoValue(value interface{}) Kind { return InvalidKind } } + +// MarshalJSON marshals the kind to a JSON string and returns an error if the kind is invalid. +func (t Kind) MarshalJSON() ([]byte, error) { + if err := t.Validate(); err != nil { + return nil, err + } + return json.Marshal(t.String()) +} + +// UnmarshalJSON unmarshals the kind from a JSON string and returns an error if the kind is invalid. +func (t *Kind) UnmarshalJSON(data []byte) error { + var s string + err := json.Unmarshal(data, &s) + if err != nil { + return err + } + k, ok := kindStrings[s] + if !ok { + return fmt.Errorf("invalid kind: %s", s) + } + *t = k + return nil +} + +var kindStrings = map[string]Kind{} + +func init() { + for i := InvalidKind + 1; i <= MAX_VALID_KIND; i++ { + kindStrings[i.String()] = i + } +} diff --git a/schema/kind_test.go b/schema/kind_test.go index a337ba278331..ec5766655943 100644 --- a/schema/kind_test.go +++ b/schema/kind_test.go @@ -263,3 +263,58 @@ func TestKindForGoValue(t *testing.T) { }) } } + +func TestKindJSON(t *testing.T) { + tt := []struct { + kind Kind + want string + expectErr bool + }{ + {StringKind, `"string"`, false}, + {BytesKind, `"bytes"`, false}, + {Int8Kind, `"int8"`, false}, + {Uint8Kind, `"uint8"`, false}, + {Int16Kind, `"int16"`, false}, + {Uint16Kind, `"uint16"`, false}, + {Int32Kind, `"int32"`, false}, + {Uint32Kind, `"uint32"`, false}, + {Int64Kind, `"int64"`, false}, + {Uint64Kind, `"uint64"`, false}, + {IntegerStringKind, `"integer"`, false}, + {DecimalStringKind, `"decimal"`, false}, + {BoolKind, `"bool"`, false}, + {TimeKind, `"time"`, false}, + {DurationKind, `"duration"`, false}, + {Float32Kind, `"float32"`, false}, + {Float64Kind, `"float64"`, false}, + {JSONKind, `"json"`, false}, + {EnumKind, `"enum"`, false}, + {AddressKind, `"address"`, false}, + {InvalidKind, `""`, true}, + {Kind(100), `""`, true}, + } + for i, tc := range tt { + t.Run(fmt.Sprintf("test %d", i), func(t *testing.T) { + b, err := json.Marshal(tc.kind) + if tc.expectErr && err == nil { + t.Errorf("test %d: expected error, got nil", i) + } + if !tc.expectErr && err != nil { + t.Errorf("test %d: unexpected error: %v", i, err) + } + if !tc.expectErr { + if string(b) != tc.want { + t.Errorf("test %d: expected %s, got %s", i, tc.want, string(b)) + } + var k Kind + err := json.Unmarshal(b, &k) + if err != nil { + t.Errorf("test %d: unexpected error: %v", i, err) + } + if k != tc.kind { + t.Errorf("test %d: expected %s, got %s", i, tc.kind, k) + } + } + }) + } +} diff --git a/schema/module_schema.go b/schema/module_schema.go index 351e5e1f4358..8fc92d9f9f65 100644 --- a/schema/module_schema.go +++ b/schema/module_schema.go @@ -1,6 +1,7 @@ package schema import ( + "encoding/json" "fmt" "sort" ) @@ -113,4 +114,59 @@ func (s ModuleSchema) EnumTypes(f func(EnumType) bool) { }) } +type moduleSchemaJson struct { + ObjectTypes []ObjectType `json:"object_types"` + EnumTypes []EnumType `json:"enum_types"` +} + +// MarshalJSON implements the json.Marshaler interface for ModuleSchema. +// It marshals the module schema into a JSON object with the object types and enum types +// under the keys "object_types" and "enum_types" respectively. +func (s ModuleSchema) MarshalJSON() ([]byte, error) { + asJson := moduleSchemaJson{} + + s.ObjectTypes(func(objType ObjectType) bool { + asJson.ObjectTypes = append(asJson.ObjectTypes, objType) + return true + }) + + s.EnumTypes(func(enumType EnumType) bool { + asJson.EnumTypes = append(asJson.EnumTypes, enumType) + return true + }) + + return json.Marshal(asJson) +} + +// UnmarshalJSON implements the json.Unmarshaler interface for ModuleSchema. +// See MarshalJSON for the JSON format. +func (s *ModuleSchema) UnmarshalJSON(data []byte) error { + asJson := moduleSchemaJson{} + + err := json.Unmarshal(data, &asJson) + if err != nil { + return err + } + + types := map[string]Type{} + + for _, objType := range asJson.ObjectTypes { + types[objType.Name] = objType + } + + for _, enumType := range asJson.EnumTypes { + types[enumType.Name] = enumType + } + + s.types = types + + // validate adds all enum types to the type map + err = s.Validate() + if err != nil { + return err + } + + return nil +} + var _ Schema = ModuleSchema{} diff --git a/schema/module_schema_test.go b/schema/module_schema_test.go index f1e6233ad859..9c356f3457f7 100644 --- a/schema/module_schema_test.go +++ b/schema/module_schema_test.go @@ -316,3 +316,27 @@ func TestModuleSchema_EnumTypes(t *testing.T) { t.Fatalf("expected %v, got %v", expected, typeNames) } } + +func TestModuleSchemaJSON(t *testing.T) { + moduleSchema := exampleSchema(t) + + b, err := moduleSchema.MarshalJSON() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + const expectedJson = `{"object_types":[{"name":"object1","key_fields":[{"name":"field1","kind":"enum","referenced_type":"enum2"}]},{"name":"object2","key_fields":[{"name":"field1","kind":"enum","referenced_type":"enum1"}]}],"enum_types":[{"name":"enum1","values":[{"name":"a","value":1},{"name":"b","value":2},{"name":"c","value":3}]},{"name":"enum2","values":[{"name":"d","value":4},{"name":"e","value":5},{"name":"f","value":6}]}]}` + if string(b) != expectedJson { + t.Fatalf("expected %s\n, got %s", expectedJson, string(b)) + } + + var moduleSchema2 ModuleSchema + err = moduleSchema2.UnmarshalJSON(b) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !reflect.DeepEqual(moduleSchema, moduleSchema2) { + t.Fatalf("expected %v, got %v", moduleSchema, moduleSchema2) + } +} diff --git a/schema/object_type.go b/schema/object_type.go index 177fbbc55cac..c35f27c7226d 100644 --- a/schema/object_type.go +++ b/schema/object_type.go @@ -6,7 +6,7 @@ import "fmt" type ObjectType struct { // Name is the name of the object type. It must be unique within the module schema amongst all object and enum // types and conform to the NameFormat regular expression. - Name string + Name string `json:"name"` // KeyFields is a list of fields that make up the primary key of the object. // It can be empty in which case indexers should assume that this object is @@ -14,19 +14,19 @@ type ObjectType struct { // object between both key and value fields. // Key fields CANNOT be nullable and Float32Kind, Float64Kind, and JSONKind types // are not allowed. - KeyFields []Field + KeyFields []Field `json:"key_fields,omitempty"` // ValueFields is a list of fields that are not part of the primary key of the object. // It can be empty in the case where all fields are part of the primary key. // Field names must be unique within the object between both key and value fields. - ValueFields []Field + ValueFields []Field `json:"value_fields,omitempty"` // RetainDeletions is a flag that indicates whether the indexer should retain // deleted rows in the database and flag them as deleted rather than actually // deleting the row. For many types of data in state, the data is deleted even // though it is still valid in order to save space. Indexers will want to have // the option of retaining such data and distinguishing from other "true" deletions. - RetainDeletions bool + RetainDeletions bool `json:"retain_deletions,omitempty"` } // TypeName implements the Type interface. diff --git a/schema/testing/appdatasim/testdata/app_sim_example_schema.txt b/schema/testing/appdatasim/testdata/app_sim_example_schema.txt index 04aeed3487e8..f542fe6fe035 100644 --- a/schema/testing/appdatasim/testdata/app_sim_example_schema.txt +++ b/schema/testing/appdatasim/testdata/app_sim_example_schema.txt @@ -1,5 +1,5 @@ -InitializeModuleData: {"ModuleName":"all_kinds","Schema":{}} -InitializeModuleData: {"ModuleName":"test_cases","Schema":{}} +InitializeModuleData: {"ModuleName":"all_kinds","Schema":{"object_types":[{"name":"test_address","key_fields":[{"name":"key","kind":"address"}],"value_fields":[{"name":"valNotNull","kind":"address"},{"name":"valNullable","kind":"address","nullable":true}]},{"name":"test_bool","key_fields":[{"name":"key","kind":"bool"}],"value_fields":[{"name":"valNotNull","kind":"bool"},{"name":"valNullable","kind":"bool","nullable":true}]},{"name":"test_bytes","key_fields":[{"name":"key","kind":"bytes"}],"value_fields":[{"name":"valNotNull","kind":"bytes"},{"name":"valNullable","kind":"bytes","nullable":true}]},{"name":"test_decimal","key_fields":[{"name":"key","kind":"decimal"}],"value_fields":[{"name":"valNotNull","kind":"decimal"},{"name":"valNullable","kind":"decimal","nullable":true}]},{"name":"test_duration","key_fields":[{"name":"key","kind":"duration"}],"value_fields":[{"name":"valNotNull","kind":"duration"},{"name":"valNullable","kind":"duration","nullable":true}]},{"name":"test_enum","key_fields":[{"name":"key","kind":"enum","referenced_type":"test_enum_type"}],"value_fields":[{"name":"valNotNull","kind":"enum","referenced_type":"test_enum_type"},{"name":"valNullable","kind":"enum","nullable":true,"referenced_type":"test_enum_type"}]},{"name":"test_float32","key_fields":[{"name":"key","kind":"int32"}],"value_fields":[{"name":"valNotNull","kind":"float32"},{"name":"valNullable","kind":"float32","nullable":true}]},{"name":"test_float64","key_fields":[{"name":"key","kind":"int32"}],"value_fields":[{"name":"valNotNull","kind":"float64"},{"name":"valNullable","kind":"float64","nullable":true}]},{"name":"test_int16","key_fields":[{"name":"key","kind":"int16"}],"value_fields":[{"name":"valNotNull","kind":"int16"},{"name":"valNullable","kind":"int16","nullable":true}]},{"name":"test_int32","key_fields":[{"name":"key","kind":"int32"}],"value_fields":[{"name":"valNotNull","kind":"int32"},{"name":"valNullable","kind":"int32","nullable":true}]},{"name":"test_int64","key_fields":[{"name":"key","kind":"int64"}],"value_fields":[{"name":"valNotNull","kind":"int64"},{"name":"valNullable","kind":"int64","nullable":true}]},{"name":"test_int8","key_fields":[{"name":"key","kind":"int8"}],"value_fields":[{"name":"valNotNull","kind":"int8"},{"name":"valNullable","kind":"int8","nullable":true}]},{"name":"test_integer","key_fields":[{"name":"key","kind":"integer"}],"value_fields":[{"name":"valNotNull","kind":"integer"},{"name":"valNullable","kind":"integer","nullable":true}]},{"name":"test_string","key_fields":[{"name":"key","kind":"string"}],"value_fields":[{"name":"valNotNull","kind":"string"},{"name":"valNullable","kind":"string","nullable":true}]},{"name":"test_time","key_fields":[{"name":"key","kind":"time"}],"value_fields":[{"name":"valNotNull","kind":"time"},{"name":"valNullable","kind":"time","nullable":true}]},{"name":"test_uint16","key_fields":[{"name":"key","kind":"uint16"}],"value_fields":[{"name":"valNotNull","kind":"uint16"},{"name":"valNullable","kind":"uint16","nullable":true}]},{"name":"test_uint32","key_fields":[{"name":"key","kind":"uint32"}],"value_fields":[{"name":"valNotNull","kind":"uint32"},{"name":"valNullable","kind":"uint32","nullable":true}]},{"name":"test_uint64","key_fields":[{"name":"key","kind":"uint64"}],"value_fields":[{"name":"valNotNull","kind":"uint64"},{"name":"valNullable","kind":"uint64","nullable":true}]},{"name":"test_uint8","key_fields":[{"name":"key","kind":"uint8"}],"value_fields":[{"name":"valNotNull","kind":"uint8"},{"name":"valNullable","kind":"uint8","nullable":true}]}],"enum_types":[{"name":"test_enum_type","values":[{"name":"foo","value":1},{"name":"bar","value":2},{"name":"baz","value":3}]}]}} +InitializeModuleData: {"ModuleName":"test_cases","Schema":{"object_types":[{"name":"ManyValues","key_fields":[{"name":"Key","kind":"string"}],"value_fields":[{"name":"Value1","kind":"int32"},{"name":"Value2","kind":"bytes"},{"name":"Value3","kind":"float64"},{"name":"Value4","kind":"uint64"}]},{"name":"RetainDeletions","key_fields":[{"name":"Key","kind":"string"}],"value_fields":[{"name":"Value1","kind":"int32"},{"name":"Value2","kind":"bytes"}],"retain_deletions":true},{"name":"Simple","key_fields":[{"name":"Key","kind":"string"}],"value_fields":[{"name":"Value1","kind":"int32"},{"name":"Value2","kind":"bytes"}]},{"name":"Singleton","value_fields":[{"name":"Value","kind":"string"},{"name":"Value2","kind":"bytes"}]},{"name":"ThreeKeys","key_fields":[{"name":"Key1","kind":"string"},{"name":"Key2","kind":"int32"},{"name":"Key3","kind":"uint64"}],"value_fields":[{"name":"Value1","kind":"int32"}]},{"name":"TwoKeys","key_fields":[{"name":"Key1","kind":"string"},{"name":"Key2","kind":"int32"}]}],"enum_types":null}} StartBlock: {1 } OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"RetainDeletions","Key":"","Value":[4602,"NwsAtcME5moByAKKwXU="],"Delete":false},{"TypeName":"Simple","Key":"","Value":[-89,"fgY="],"Delete":false}]} OnObjectUpdate: {"ModuleName":"test_cases","Updates":[{"TypeName":"Singleton","Key":null,"Value":["֑Ⱥ|@!`",""],"Delete":false}]} diff --git a/schema/testing/json_test.go b/schema/testing/json_test.go new file mode 100644 index 000000000000..7416924d69e9 --- /dev/null +++ b/schema/testing/json_test.go @@ -0,0 +1,23 @@ +package schematesting + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + "pgregory.net/rapid" + + "cosmossdk.io/schema" +) + +func TestModuleSchemaJSON(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + modSchema := ModuleSchemaGen().Draw(t, "moduleSchema") + bz, err := json.Marshal(modSchema) + require.NoError(t, err) + var modSchema2 schema.ModuleSchema + err = json.Unmarshal(bz, &modSchema2) + require.NoError(t, err) + require.Equal(t, modSchema, modSchema2) + }) +}