Skip to content

Commit

Permalink
feat(schema): add JSON marshaling for ModuleSchema (#21371)
Browse files Browse the repository at this point in the history
  • Loading branch information
aaronc authored Aug 28, 2024
1 parent 8ca94d9 commit 89ec787
Show file tree
Hide file tree
Showing 10 changed files with 267 additions and 16 deletions.
10 changes: 5 additions & 5 deletions schema/enum.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,28 +12,28 @@ 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.
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.
Expand Down
12 changes: 7 additions & 5 deletions schema/field.go
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
60 changes: 60 additions & 0 deletions schema/field_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package schema

import (
"encoding/json"
"reflect"
"strings"
"testing"
)
Expand Down Expand Up @@ -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}},
Expand Down
31 changes: 31 additions & 0 deletions schema/kind.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
55 changes: 55 additions & 0 deletions schema/kind_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
})
}
}
56 changes: 56 additions & 0 deletions schema/module_schema.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package schema

import (
"encoding/json"
"fmt"
"sort"
)
Expand Down Expand Up @@ -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{}
24 changes: 24 additions & 0 deletions schema/module_schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
8 changes: 4 additions & 4 deletions schema/object_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,27 @@ 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
// a singleton and only has one value. Field names must be unique within the
// 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.
Expand Down
4 changes: 2 additions & 2 deletions schema/testing/appdatasim/testdata/app_sim_example_schema.txt
Original file line number Diff line number Diff line change
@@ -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 <nil> <nil>}
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}]}
Expand Down
23 changes: 23 additions & 0 deletions schema/testing/json_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}

0 comments on commit 89ec787

Please sign in to comment.