diff --git a/indexer/base/enum.go b/indexer/base/enum.go index b1275d8a7a..f3d51e9ea5 100644 --- a/indexer/base/enum.go +++ b/indexer/base/enum.go @@ -1,10 +1,46 @@ package indexerbase +import "fmt" + // EnumDefinition represents the definition of an enum type. type EnumDefinition struct { - // Name is the name of the enum type. + // Name is the name of the enum type. It must conform to the NameFormat regular expression. Name string - // Values is a list of distinct values that are part of the enum type. + // 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 []string } + +// Validate validates the enum definition. +func (e EnumDefinition) Validate() error { + if !ValidateName(e.Name) { + return fmt.Errorf("invalid enum definition name %q", e.Name) + } + + if len(e.Values) == 0 { + return fmt.Errorf("enum definition values cannot be empty") + } + seen := make(map[string]bool, len(e.Values)) + for i, v := range e.Values { + if !ValidateName(v) { + return fmt.Errorf("invalid enum definition value %q at index %d for enum %s", v, i, e.Name) + } + + if seen[v] { + return fmt.Errorf("duplicate enum definition value %q for enum %s", v, e.Name) + } + seen[v] = true + } + return nil +} + +// ValidateValue validates that the value is a valid enum value. +func (e EnumDefinition) ValidateValue(value string) error { + for _, v := range e.Values { + if v == value { + return nil + } + } + return fmt.Errorf("value %q is not a valid enum value for %s", value, e.Name) +} diff --git a/indexer/base/enum_test.go b/indexer/base/enum_test.go new file mode 100644 index 0000000000..bff06eba81 --- /dev/null +++ b/indexer/base/enum_test.go @@ -0,0 +1,106 @@ +package indexerbase + +import ( + "strings" + "testing" +) + +func TestEnumDefinition_Validate(t *testing.T) { + tests := []struct { + name string + enum EnumDefinition + errContains string + }{ + { + name: "valid enum", + enum: EnumDefinition{ + Name: "test", + Values: []string{"a", "b", "c"}, + }, + errContains: "", + }, + { + name: "empty name", + enum: EnumDefinition{ + Name: "", + Values: []string{"a", "b", "c"}, + }, + errContains: "invalid enum definition name", + }, + { + name: "empty values", + enum: EnumDefinition{ + Name: "test", + Values: []string{}, + }, + errContains: "enum definition values cannot be empty", + }, + { + name: "empty value", + enum: EnumDefinition{ + Name: "test", + Values: []string{"a", "", "c"}, + }, + errContains: "invalid enum definition value", + }, + { + name: "duplicate value", + enum: EnumDefinition{ + Name: "test", + Values: []string{"a", "b", "a"}, + }, + errContains: "duplicate enum definition value \"a\" for enum test", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.enum.Validate() + if tt.errContains == "" { + if err != nil { + t.Errorf("expected valid enum definition to pass validation, got: %v", err) + } + } else { + if err == nil { + t.Errorf("expected invalid enum definition to fail validation, got nil error") + } else if !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("expected error to contain %s, got: %v", tt.errContains, err) + } + } + }) + } +} + +func TestEnumDefinition_ValidateValue(t *testing.T) { + enum := EnumDefinition{ + Name: "test", + Values: []string{"a", "b", "c"}, + } + + tests := []struct { + value string + errContains string + }{ + {"a", ""}, + {"b", ""}, + {"c", ""}, + {"d", "value \"d\" is not a valid enum value for test"}, + } + + for _, tt := range tests { + t.Run(tt.value, func(t *testing.T) { + err := enum.ValidateValue(tt.value) + if tt.errContains == "" { + if err != nil { + t.Errorf("expected valid enum value to pass validation, got: %v", err) + } + } else { + if err == nil { + t.Errorf("expected invalid enum value to fail validation, got nil error") + } else if !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("expected error to contain %s, got: %v", tt.errContains, err) + } + } + }) + } +} diff --git a/indexer/base/field.go b/indexer/base/field.go index 713abdeb4c..fb91bada6b 100644 --- a/indexer/base/field.go +++ b/indexer/base/field.go @@ -1,8 +1,10 @@ package indexerbase +import "fmt" + // Field represents a field in an object type. type Field struct { - // Name is the name of the field. + // Name is the name of the field. It must conform to the NameFormat regular expression. Name string // Kind is the basic type of the field. @@ -15,5 +17,61 @@ type Field struct { AddressPrefix string // EnumDefinition is the definition of the enum type and is only valid when Kind is EnumKind. + // The same enum types can be reused in the same module schema, but they always must contain + // the same values for the same enum name. This possibly introduces some duplication of + // definitions but makes it easier to reason about correctness and validation in isolation. EnumDefinition EnumDefinition } + +// Validate validates the field. +func (c Field) Validate() error { + // valid name + if !ValidateName(c.Name) { + return fmt.Errorf("invalid field name %q", c.Name) + } + + // valid kind + if err := c.Kind.Validate(); err != nil { + return fmt.Errorf("invalid field kind for %q: %w", c.Name, err) + } + + // address prefix only valid with Bech32AddressKind + if c.Kind == Bech32AddressKind && c.AddressPrefix == "" { + return fmt.Errorf("missing address prefix for field %q", c.Name) + } else if c.Kind != Bech32AddressKind && c.AddressPrefix != "" { + return fmt.Errorf("address prefix is only valid for field %q with type Bech32AddressKind", c.Name) + } + + // enum definition only valid with EnumKind + if c.Kind == EnumKind { + if err := c.EnumDefinition.Validate(); err != nil { + return fmt.Errorf("invalid enum definition for field %q: %w", c.Name, err) + } + } else if c.Kind != EnumKind && (c.EnumDefinition.Name != "" || c.EnumDefinition.Values != nil) { + return fmt.Errorf("enum definition is only valid for field %q with type EnumKind", c.Name) + } + + return nil +} + +// ValidateValue validates that the value conforms to the field's kind and nullability. +// Unlike Kind.ValidateValue, it also checks that the value conforms to the EnumDefinition +// if the field is an EnumKind. +func (c Field) ValidateValue(value interface{}) error { + if value == nil { + if !c.Nullable { + return fmt.Errorf("field %q cannot be null", c.Name) + } + return nil + } + err := c.Kind.ValidateValueType(value) + if err != nil { + return fmt.Errorf("invalid value for field %q: %w", c.Name, err) + } + + if c.Kind == EnumKind { + return c.EnumDefinition.ValidateValue(value.(string)) + } + + return nil +} diff --git a/indexer/base/field_test.go b/indexer/base/field_test.go new file mode 100644 index 0000000000..8e65cade34 --- /dev/null +++ b/indexer/base/field_test.go @@ -0,0 +1,183 @@ +package indexerbase + +import ( + "strings" + "testing" +) + +func TestField_Validate(t *testing.T) { + tests := []struct { + name string + field Field + errContains string + }{ + { + name: "valid field", + field: Field{ + Name: "field1", + Kind: StringKind, + }, + errContains: "", + }, + { + name: "empty name", + field: Field{ + Name: "", + Kind: StringKind, + }, + errContains: "invalid field name", + }, + { + name: "invalid kind", + field: Field{ + Name: "field1", + Kind: InvalidKind, + }, + errContains: "invalid field kind", + }, + { + name: "missing address prefix", + field: Field{ + Name: "field1", + Kind: Bech32AddressKind, + }, + errContains: "missing address prefix", + }, + { + name: "address prefix with non-Bech32AddressKind", + field: Field{ + Name: "field1", + Kind: StringKind, + AddressPrefix: "prefix", + }, + errContains: "address prefix is only valid for field \"field1\" with type Bech32AddressKind", + }, + { + name: "invalid enum definition", + field: Field{ + Name: "field1", + Kind: EnumKind, + }, + errContains: "invalid enum definition", + }, + { + name: "enum definition with non-EnumKind", + field: Field{ + Name: "field1", + Kind: StringKind, + EnumDefinition: EnumDefinition{Name: "enum"}, + }, + errContains: "enum definition is only valid for field \"field1\" with type EnumKind", + }, + { + name: "valid enum", + field: Field{ + Name: "field1", + Kind: EnumKind, + EnumDefinition: EnumDefinition{Name: "enum", Values: []string{"a", "b"}}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.field.Validate() + if tt.errContains == "" { + if err != nil { + t.Errorf("expected no error, got: %v", err) + } + } else { + if err == nil { + t.Errorf("expected error, got nil") + } else if !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("expected error contains: %s, got: %v", tt.errContains, err) + } + } + }) + } +} + +func TestField_ValidateValue(t *testing.T) { + tests := []struct { + name string + field Field + value interface{} + errContains string + }{ + { + name: "valid field", + field: Field{ + Name: "field1", + Kind: StringKind, + }, + value: "value", + errContains: "", + }, + { + name: "null non-nullable field", + field: Field{ + Name: "field1", + Kind: StringKind, + Nullable: false, + }, + value: nil, + errContains: "cannot be null", + }, + { + name: "null nullable field", + field: Field{ + Name: "field1", + Kind: StringKind, + Nullable: true, + }, + value: nil, + errContains: "", + }, + { + name: "invalid value", + field: Field{ + Name: "field1", + Kind: StringKind, + }, + value: 1, + errContains: "invalid value for field \"field1\"", + }, + { + name: "valid enum", + field: Field{ + Name: "field1", + Kind: EnumKind, + EnumDefinition: EnumDefinition{Name: "enum", Values: []string{"a", "b"}}, + }, + value: "a", + errContains: "", + }, + { + name: "invalid enum", + field: Field{ + Name: "field1", + Kind: EnumKind, + EnumDefinition: EnumDefinition{Name: "enum", Values: []string{"a", "b"}}, + }, + value: "c", + errContains: "not a valid enum value", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.field.ValidateValue(tt.value) + if tt.errContains == "" { + if err != nil { + t.Errorf("expected no error, got: %v", err) + } + } else { + if err == nil { + t.Errorf("expected error, got nil") + } else if !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("expected error contains: %s, got: %v", tt.errContains, err) + } + } + }) + } +} diff --git a/indexer/base/fields.go b/indexer/base/fields.go new file mode 100644 index 0000000000..4c9aaf1b9f --- /dev/null +++ b/indexer/base/fields.go @@ -0,0 +1,71 @@ +package indexerbase + +import "fmt" + +// ValidateForKeyFields validates that the value conforms to the set of fields as a Key in an ObjectUpdate. +// See ObjectUpdate.Key for documentation on the requirements of such keys. +func ValidateForKeyFields(keyFields []Field, value interface{}) error { + return validateFieldsValue(keyFields, value) +} + +// ValidateForValueFields validates that the value conforms to the set of fields as a Value in an ObjectUpdate. +// See ObjectUpdate.Value for documentation on the requirements of such values. +func ValidateForValueFields(valueFields []Field, value interface{}) error { + valueUpdates, ok := value.(ValueUpdates) + if !ok { + return validateFieldsValue(valueFields, value) + } + + values := map[string]interface{}{} + err := valueUpdates.Iterate(func(fieldname string, value interface{}) bool { + values[fieldname] = value + return true + }) + if err != nil { + return err + } + + for _, field := range valueFields { + v, ok := values[field.Name] + if !ok { + continue + } + + if err := field.ValidateValue(v); err != nil { + return err + } + + delete(values, field.Name) + } + + if len(values) > 0 { + return fmt.Errorf("unexpected values in ValueUpdates: %v", values) + } + + return nil +} + +func validateFieldsValue(fields []Field, value interface{}) error { + if len(fields) == 0 { + return nil + } + + if len(fields) == 1 { + return fields[0].ValidateValue(value) + } + + values, ok := value.([]interface{}) + if !ok { + return fmt.Errorf("expected slice of values for key fields, got %T", value) + } + + if len(fields) != len(values) { + return fmt.Errorf("expected %d key fields, got %d values", len(fields), len(value.([]interface{}))) + } + for i, field := range fields { + if err := field.ValidateValue(values[i]); err != nil { + return err + } + } + return nil +} diff --git a/indexer/base/fields_test.go b/indexer/base/fields_test.go new file mode 100644 index 0000000000..53470b5d5d --- /dev/null +++ b/indexer/base/fields_test.go @@ -0,0 +1,143 @@ +package indexerbase + +import ( + "strings" + "testing" +) + +func TestValidateForKeyFields(t *testing.T) { + tests := []struct { + name string + keyFields []Field + key interface{} + errContains string + }{ + { + name: "no key fields", + keyFields: nil, + key: nil, + }, + { + name: "single key field, valid", + keyFields: object1Type.KeyFields, + key: "hello", + errContains: "", + }, + { + name: "single key field, invalid", + keyFields: object1Type.KeyFields, + key: []interface{}{"value"}, + errContains: "invalid value", + }, + { + name: "multiple key fields, valid", + keyFields: object2Type.KeyFields, + key: []interface{}{"hello", int32(42)}, + }, + { + name: "multiple key fields, not a slice", + keyFields: object2Type.KeyFields, + key: map[string]interface{}{"field1": "hello", "field2": "42"}, + errContains: "expected slice of values", + }, + { + name: "multiple key fields, wrong number of values", + keyFields: object2Type.KeyFields, + key: []interface{}{"hello"}, + errContains: "expected 2 key fields", + }, + { + name: "multiple key fields, invalid value", + keyFields: object2Type.KeyFields, + key: []interface{}{"hello", "abc"}, + errContains: "invalid value", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateForKeyFields(tt.keyFields, tt.key) + if tt.errContains == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } else { + if err == nil || !strings.Contains(err.Error(), tt.errContains) { + t.Fatalf("expected error to contain %q, got: %v", tt.errContains, err) + } + } + }) + } +} + +func TestValidateForValueFields(t *testing.T) { + tests := []struct { + name string + valueFields []Field + value interface{} + errContains string + }{ + { + name: "no value fields", + valueFields: nil, + value: nil, + }, + { + name: "single value field, valid", + valueFields: []Field{ + { + Name: "field1", + Kind: StringKind, + }, + }, + value: "hello", + errContains: "", + }, + { + name: "value updates, empty", + valueFields: object3Type.ValueFields, + value: MapValueUpdates(map[string]interface{}{}), + }, + { + name: "value updates, 1 field valid", + valueFields: object3Type.ValueFields, + value: MapValueUpdates(map[string]interface{}{ + "field1": "hello", + }), + }, + { + name: "value updates, 2 fields, 1 invalid", + valueFields: object3Type.ValueFields, + value: MapValueUpdates(map[string]interface{}{ + "field1": "hello", + "field2": "abc", + }), + errContains: "expected int32", + }, + { + name: "value updates, extra value", + valueFields: object3Type.ValueFields, + value: MapValueUpdates(map[string]interface{}{ + "field1": "hello", + "field2": int32(42), + "field3": "extra", + }), + errContains: "unexpected values", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateForValueFields(tt.valueFields, tt.value) + if tt.errContains == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } else { + if err == nil || !strings.Contains(err.Error(), tt.errContains) { + t.Fatalf("expected error to contain %q, got: %v", tt.errContains, err) + } + } + }) + } +} diff --git a/indexer/base/kind.go b/indexer/base/kind.go index 064a6543be..16f7a2384c 100644 --- a/indexer/base/kind.go +++ b/indexer/base/kind.go @@ -1,5 +1,12 @@ package indexerbase +import ( + "encoding/json" + "fmt" + "regexp" + "time" +) + // Kind represents the basic type of a field in an object. // Each kind defines the types of go values which should be accepted // by listeners and generated by decoders when providing entity updates. @@ -9,8 +16,7 @@ const ( // InvalidKind indicates that an invalid type. InvalidKind Kind = iota - // StringKind is a string type and values of this type must be of the go type string - // or implement fmt.Stringer(). + // StringKind is a string type and values of this type must be of the go type string. StringKind // BytesKind is a bytes type and values of this type must be of the go type []byte. @@ -41,14 +47,12 @@ const ( Uint64Kind // IntegerKind represents an arbitrary precision integer number. Values of this type must - // be of the go type int64, string or a type that implements fmt.Stringer with the resulted string - // formatted as an integer number. + // be of the go type string and formatted as base10 integers, specifically matching to + // the IntegerFormat regex. IntegerKind // DecimalKind represents an arbitrary precision decimal or integer number. Values of this type - // must be of the go type string or a type that implements fmt.Stringer with the resulting string - // formatted as decimal numbers with an optional fractional part. Exponential E-notation - // is supported but NaN and Infinity are not. + // must be of the go type string and match the DecimalFormat regex. DecimalKind // BoolKind is a boolean type and values of this type must be of the go type bool. @@ -66,17 +70,280 @@ const ( // Float64Kind is a float64 type and values of this type must be of the go type float64. Float64Kind - // Bech32AddressKind is a bech32 address type and values of this type must be of the go type string or []byte - // or a type which implements fmt.Stringer. Fields of this type are expected to set the AddressPrefix field - // in the field definition to the bech32 address prefix. + // Bech32AddressKind is a bech32 address type and values of this type must be of the go type []byte. + // Fields of this type are expected to set the AddressPrefix field in the field definition to the + // bech32 address prefix so that indexers can properly convert them to strings. Bech32AddressKind - // EnumKind is an enum type and values of this type must be of the go type string or implement fmt.Stringer. + // EnumKind is an enum type and values of this type must be of the go type string. // Fields of this type are expected to set the EnumDefinition field in the field definition to the enum // definition. EnumKind - // JSONKind is a JSON type and values of this type can either be of go type json.RawMessage - // or any type that can be marshaled to JSON using json.Marshal. + // JSONKind is a JSON type and values of this type should be of go type json.RawMessage and represent + // valid JSON. JSONKind ) + +// MAX_VALID_KIND is the maximum valid kind value. +const MAX_VALID_KIND = JSONKind + +const ( + // IntegerFormat is a regex that describes the format integer number strings must match. It specifies + // that integers may have at most 100 digits. + IntegerFormat = `^-?[0-9]{1,100}$` + + // DecimalFormat is a regex that describes the format decimal number strings must match. It specifies + // that decimals may have at most 50 digits before and after the decimal point and may have an optional + // exponent of up to 2 digits. These restrictions ensure that the decimal can be accurately represented + // by a wide variety of implementations. + DecimalFormat = `^-?[0-9]{1,50}(\.[0-9]{1,50})?([eE][-+]?[0-9]{1,2})?$` +) + +// Validate returns an errContains if the kind is invalid. +func (t Kind) Validate() error { + if t <= InvalidKind { + return fmt.Errorf("unknown type: %d", t) + } + if t > JSONKind { + return fmt.Errorf("invalid type: %d", t) + } + return nil +} + +// String returns a string representation of the kind. +func (t Kind) String() string { + switch t { + case StringKind: + return "string" + case BytesKind: + return "bytes" + case Int8Kind: + return "int8" + case Uint8Kind: + return "uint8" + case Int16Kind: + return "int16" + case Uint16Kind: + return "uint16" + case Int32Kind: + return "int32" + case Uint32Kind: + return "uint32" + case Int64Kind: + return "int64" + case Uint64Kind: + return "uint64" + case DecimalKind: + return "decimal" + case IntegerKind: + return "integer" + case BoolKind: + return "bool" + case TimeKind: + return "time" + case DurationKind: + return "duration" + case Float32Kind: + return "float32" + case Float64Kind: + return "float64" + case Bech32AddressKind: + return "bech32address" + case EnumKind: + return "enum" + case JSONKind: + return "json" + default: + return fmt.Sprintf("invalid(%d)", t) + } +} + +// ValidateValueType returns an errContains if the value does not conform to the expected go type. +// Some fields may accept nil values, however, this method does not have any notion of +// nullability. This method only validates that the go type of the value is correct for the kind +// and does not validate string or json formats. Kind.ValidateValue does a more thorough validation +// of number and json string formatting. +func (t Kind) ValidateValueType(value interface{}) error { + switch t { + case StringKind: + _, ok := value.(string) + if !ok { + return fmt.Errorf("expected string, got %T", value) + } + case BytesKind: + _, ok := value.([]byte) + if !ok { + return fmt.Errorf("expected []byte, got %T", value) + } + case Int8Kind: + _, ok := value.(int8) + if !ok { + return fmt.Errorf("expected int8, got %T", value) + } + case Uint8Kind: + _, ok := value.(uint8) + if !ok { + return fmt.Errorf("expected uint8, got %T", value) + } + case Int16Kind: + _, ok := value.(int16) + if !ok { + return fmt.Errorf("expected int16, got %T", value) + } + case Uint16Kind: + _, ok := value.(uint16) + if !ok { + return fmt.Errorf("expected uint16, got %T", value) + } + case Int32Kind: + _, ok := value.(int32) + if !ok { + return fmt.Errorf("expected int32, got %T", value) + } + case Uint32Kind: + _, ok := value.(uint32) + if !ok { + return fmt.Errorf("expected uint32, got %T", value) + } + case Int64Kind: + _, ok := value.(int64) + if !ok { + return fmt.Errorf("expected int64, got %T", value) + } + case Uint64Kind: + _, ok := value.(uint64) + if !ok { + return fmt.Errorf("expected uint64, got %T", value) + } + case IntegerKind: + _, ok := value.(string) + if !ok { + return fmt.Errorf("expected string, got %T", value) + } + + case DecimalKind: + _, ok := value.(string) + if !ok { + return fmt.Errorf("expected string, got %T", value) + } + case BoolKind: + _, ok := value.(bool) + if !ok { + return fmt.Errorf("expected bool, got %T", value) + } + case TimeKind: + _, ok := value.(time.Time) + if !ok { + return fmt.Errorf("expected time.Time, got %T", value) + } + case DurationKind: + _, ok := value.(time.Duration) + if !ok { + return fmt.Errorf("expected time.Duration, got %T", value) + } + case Float32Kind: + _, ok := value.(float32) + if !ok { + return fmt.Errorf("expected float32, got %T", value) + } + case Float64Kind: + _, ok := value.(float64) + if !ok { + return fmt.Errorf("expected float64, got %T", value) + } + case Bech32AddressKind: + _, ok := value.([]byte) + if !ok { + return fmt.Errorf("expected []byte, got %T", value) + } + case EnumKind: + _, ok := value.(string) + if !ok { + return fmt.Errorf("expected string, got %T", value) + } + case JSONKind: + _, ok := value.(json.RawMessage) + if !ok { + return fmt.Errorf("expected json.RawMessage, got %T", value) + } + default: + return fmt.Errorf("invalid type: %d", t) + } + return nil +} + +// ValidateValue returns an errContains if the value does not conform to the expected go type and format. +// It is more thorough, but slower, than Kind.ValidateValueType and validates that Integer, Decimal and JSON +// values are formatted correctly. It cannot validate enum values because Kind's do not have enum schemas. +func (t Kind) ValidateValue(value interface{}) error { + err := t.ValidateValueType(value) + if err != nil { + return err + } + + switch t { + case IntegerKind: + if !integerRegex.Match([]byte(value.(string))) { + return fmt.Errorf("expected base10 integer, got %s", value) + } + case DecimalKind: + if !decimalRegex.Match([]byte(value.(string))) { + return fmt.Errorf("expected decimal number, got %s", value) + } + case JSONKind: + if !json.Valid(value.(json.RawMessage)) { + return fmt.Errorf("expected valid JSON, got %s", value) + } + default: + return nil + } + return nil +} + +var ( + integerRegex = regexp.MustCompile(IntegerFormat) + decimalRegex = regexp.MustCompile(DecimalFormat) +) + +// KindForGoValue finds the simplest kind that can represent the given go value. It will not, however, +// return kinds such as IntegerKind, DecimalKind, Bech32AddressKind, or EnumKind which all can be +// represented as strings. +func KindForGoValue(value interface{}) Kind { + switch value.(type) { + case string: + return StringKind + case []byte: + return BytesKind + case int8: + return Int8Kind + case uint8: + return Uint8Kind + case int16: + return Int16Kind + case uint16: + return Uint16Kind + case int32: + return Int32Kind + case uint32: + return Uint32Kind + case int64: + return Int64Kind + case uint64: + return Uint64Kind + case float32: + return Float32Kind + case float64: + return Float64Kind + case bool: + return BoolKind + case time.Time: + return TimeKind + case time.Duration: + return DurationKind + case json.RawMessage: + return JSONKind + default: + return InvalidKind + } +} diff --git a/indexer/base/kind_test.go b/indexer/base/kind_test.go new file mode 100644 index 0000000000..e18f6e9a65 --- /dev/null +++ b/indexer/base/kind_test.go @@ -0,0 +1,261 @@ +package indexerbase + +import ( + "encoding/json" + "fmt" + "testing" + "time" +) + +func TestKind_Validate(t *testing.T) { + for kind := InvalidKind + 1; kind <= MAX_VALID_KIND; kind++ { + if err := kind.Validate(); err != nil { + t.Errorf("expected valid kind %s to pass validation, got: %v", kind, err) + } + } + + invalidKinds := []Kind{ + Kind(-1), + InvalidKind, + Kind(100), + } + + for _, kind := range invalidKinds { + if err := kind.Validate(); err == nil { + t.Errorf("expected invalid kind %s to fail validation, got: %v", kind, err) + } + } +} + +func TestKind_ValidateValueType(t *testing.T) { + tests := []struct { + kind Kind + value interface{} + valid bool + }{ + {kind: StringKind, value: "hello", valid: true}, + {kind: StringKind, value: []byte("hello"), valid: false}, + {kind: BytesKind, value: []byte("hello"), valid: true}, + {kind: BytesKind, value: "hello", valid: false}, + {kind: Int8Kind, value: int8(1), valid: true}, + {kind: Int8Kind, value: int16(1), valid: false}, + {kind: Uint8Kind, value: uint8(1), valid: true}, + {kind: Uint8Kind, value: uint16(1), valid: false}, + {kind: Int16Kind, value: int16(1), valid: true}, + {kind: Int16Kind, value: int32(1), valid: false}, + {kind: Uint16Kind, value: uint16(1), valid: true}, + {kind: Uint16Kind, value: uint32(1), valid: false}, + {kind: Int32Kind, value: int32(1), valid: true}, + {kind: Int32Kind, value: int64(1), valid: false}, + {kind: Uint32Kind, value: uint32(1), valid: true}, + {kind: Uint32Kind, value: uint64(1), valid: false}, + {kind: Int64Kind, value: int64(1), valid: true}, + {kind: Int64Kind, value: int32(1), valid: false}, + {kind: Uint64Kind, value: uint64(1), valid: true}, + {kind: Uint64Kind, value: uint32(1), valid: false}, + {kind: IntegerKind, value: "1", valid: true}, + {kind: IntegerKind, value: int32(1), valid: false}, + {kind: DecimalKind, value: "1.0", valid: true}, + {kind: DecimalKind, value: "1", valid: true}, + {kind: DecimalKind, value: "1.1e4", valid: true}, + {kind: DecimalKind, value: int32(1), valid: false}, + {kind: Bech32AddressKind, value: []byte("hello"), valid: true}, + {kind: Bech32AddressKind, value: 1, valid: false}, + {kind: BoolKind, value: true, valid: true}, + {kind: BoolKind, value: false, valid: true}, + {kind: BoolKind, value: 1, valid: false}, + {kind: EnumKind, value: "hello", valid: true}, + {kind: EnumKind, value: 1, valid: false}, + {kind: TimeKind, value: time.Now(), valid: true}, + {kind: TimeKind, value: "hello", valid: false}, + {kind: DurationKind, value: time.Second, valid: true}, + {kind: DurationKind, value: "hello", valid: false}, + {kind: Float32Kind, value: float32(1.0), valid: true}, + {kind: Float32Kind, value: float64(1.0), valid: false}, + {kind: Float64Kind, value: float64(1.0), valid: true}, + {kind: Float64Kind, value: float32(1.0), valid: false}, + {kind: JSONKind, value: json.RawMessage("{}"), valid: true}, + {kind: JSONKind, value: "hello", valid: false}, + {kind: InvalidKind, value: "hello", valid: false}, + } + + for i, tt := range tests { + t.Run(fmt.Sprintf("test %d", i), func(t *testing.T) { + err := tt.kind.ValidateValueType(tt.value) + if tt.valid && err != nil { + t.Errorf("test %d: expected valid value %v for kind %s to pass validation, got: %v", i, tt.value, tt.kind, err) + } + if !tt.valid && err == nil { + t.Errorf("test %d: expected invalid value %v for kind %s to fail validation, got: %v", i, tt.value, tt.kind, err) + } + }) + } + + // nils get rejected + for kind := InvalidKind + 1; kind <= MAX_VALID_KIND; kind++ { + if err := kind.ValidateValueType(nil); err == nil { + t.Errorf("expected nil value to fail validation for kind %s", kind) + } + } +} + +func TestKind_ValidateValue(t *testing.T) { + tests := []struct { + kind Kind + value interface{} + valid bool + }{ + // check a few basic cases that should get caught be ValidateValueType + {StringKind, "hello", true}, + {Int64Kind, int64(1), true}, + {Int32Kind, "abc", false}, + {BytesKind, nil, false}, + // check integer, decimal and json more thoroughly + {IntegerKind, "1", true}, + {IntegerKind, "0", true}, + {IntegerKind, "10", true}, + {IntegerKind, "-100", true}, + {IntegerKind, "1.0", false}, + {IntegerKind, "00", true}, // leading zeros are allowed + {IntegerKind, "001", true}, + {IntegerKind, "-01", true}, + // 100 digits + {IntegerKind, "1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", true}, + // more than 100 digits + {IntegerKind, "10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", false}, + {IntegerKind, "", false}, + {IntegerKind, "abc", false}, + {IntegerKind, "abc100", false}, + {DecimalKind, "1.0", true}, + {DecimalKind, "0.0", true}, + {DecimalKind, "-100.075", true}, + {DecimalKind, "1002346.000", true}, + {DecimalKind, "0", true}, + {DecimalKind, "10", true}, + {DecimalKind, "-100", true}, + {DecimalKind, "1", true}, + {DecimalKind, "1.0e4", true}, + {DecimalKind, "1.0e-4", true}, + {DecimalKind, "1.0e+4", true}, + {DecimalKind, "1.0e", false}, + {DecimalKind, "1.0e4.0", false}, + {DecimalKind, "1.0e-4.0", false}, + {DecimalKind, "1.0e+4.0", false}, + {DecimalKind, "-1.0e-4", true}, + {DecimalKind, "-1.0e+4", true}, + {DecimalKind, "-1.0E4", true}, + {DecimalKind, "1E-9", true}, + {DecimalKind, "1E-99", true}, + {DecimalKind, "1E+9", true}, + {DecimalKind, "1E+99", true}, + // 50 digits before and after the decimal point + {DecimalKind, "10000000000000000000000000000000000000000000000000.10000000000000000000000000000000000000000000000001", true}, + // too many digits before the decimal point + {DecimalKind, "10000000000000000000000000000000000000000000000000000000000000000000000000", false}, + // too many digits after the decimal point + {DecimalKind, "1.0000000000000000000000000000000000000000000000000000000000000000000000001", false}, + // exponent too big + {DecimalKind, "1E-999", false}, + {DecimalKind, "", false}, + {DecimalKind, "abc", false}, + {DecimalKind, "abc", false}, + {JSONKind, json.RawMessage(`{"a":10}`), true}, + {JSONKind, json.RawMessage("10"), true}, + {JSONKind, json.RawMessage("10.0"), true}, + {JSONKind, json.RawMessage("true"), true}, + {JSONKind, json.RawMessage("null"), true}, + {JSONKind, json.RawMessage(`"abc"`), true}, + {JSONKind, json.RawMessage(`[1,true,0.1,"abc",{"b":3}]`), true}, + {JSONKind, json.RawMessage(`"abc`), false}, + {JSONKind, json.RawMessage(`tru`), false}, + {JSONKind, json.RawMessage(`[`), false}, + {JSONKind, json.RawMessage(`{`), false}, + } + + for i, tt := range tests { + t.Run(fmt.Sprintf("test %v %s", tt.kind, tt.value), func(t *testing.T) { + err := tt.kind.ValidateValue(tt.value) + if tt.valid && err != nil { + t.Errorf("test %d: expected valid value %v for kind %s to pass validation, got: %v", i, tt.value, tt.kind, err) + } + if !tt.valid && err == nil { + t.Errorf("test %d: expected invalid value %v for kind %s to fail validation, got: %v", i, tt.value, tt.kind, err) + } + }) + } +} + +func TestKind_String(t *testing.T) { + tests := []struct { + kind Kind + want string + }{ + {StringKind, "string"}, + {BytesKind, "bytes"}, + {Int8Kind, "int8"}, + {Uint8Kind, "uint8"}, + {Int16Kind, "int16"}, + {Uint16Kind, "uint16"}, + {Int32Kind, "int32"}, + {Uint32Kind, "uint32"}, + {Int64Kind, "int64"}, + {Uint64Kind, "uint64"}, + {IntegerKind, "integer"}, + {DecimalKind, "decimal"}, + {BoolKind, "bool"}, + {TimeKind, "time"}, + {DurationKind, "duration"}, + {Float32Kind, "float32"}, + {Float64Kind, "float64"}, + {JSONKind, "json"}, + {EnumKind, "enum"}, + {Bech32AddressKind, "bech32address"}, + {InvalidKind, "invalid(0)"}, + } + for i, tt := range tests { + t.Run(fmt.Sprintf("test %s", tt.kind), func(t *testing.T) { + if got := tt.kind.String(); got != tt.want { + t.Errorf("test %d: Kind.String() = %v, want %v", i, got, tt.want) + } + }) + } +} + +func TestKindForGoValue(t *testing.T) { + tests := []struct { + value interface{} + want Kind + }{ + {"hello", StringKind}, + {[]byte("hello"), BytesKind}, + {int8(1), Int8Kind}, + {uint8(1), Uint8Kind}, + {int16(1), Int16Kind}, + {uint16(1), Uint16Kind}, + {int32(1), Int32Kind}, + {uint32(1), Uint32Kind}, + {int64(1), Int64Kind}, + {uint64(1), Uint64Kind}, + {float32(1.0), Float32Kind}, + {float64(1.0), Float64Kind}, + {true, BoolKind}, + {time.Now(), TimeKind}, + {time.Second, DurationKind}, + {json.RawMessage("{}"), JSONKind}, + {map[string]interface{}{"a": 1}, InvalidKind}, + } + for i, tt := range tests { + t.Run(fmt.Sprintf("test %d", i), func(t *testing.T) { + if got := KindForGoValue(tt.value); got != tt.want { + t.Errorf("test %d: KindForGoValue(%v) = %v, want %v", i, tt.value, got, tt.want) + } + + // for valid kinds check valid value + if tt.want.Validate() == nil { + if err := tt.want.ValidateValue(tt.value); err != nil { + t.Errorf("test %d: expected valid value %v for kind %s to pass validation, got: %v", i, tt.value, tt.want, err) + } + } + }) + } +} diff --git a/indexer/base/listener.go b/indexer/base/listener.go index f0accb6208..26d73ad71f 100644 --- a/indexer/base/listener.go +++ b/indexer/base/listener.go @@ -31,6 +31,7 @@ type Listener struct { OnEvent func(EventData) error // OnKVPair is called when a key-value has been written to the store for a given module. + // Module names must conform to the NameFormat regular expression. OnKVPair func(moduleName string, key, value []byte, delete bool) error // Commit is called when state is committed, usually at the end of a block. Any @@ -43,12 +44,12 @@ type Listener struct { // should ensure that they have performed whatever initialization steps (such as database // migrations) required to receive OnObjectUpdate events for the given module. If the // indexer's schema is incompatible with the module's on-chain schema, the listener should return - // an error. + // an error. Module names must conform to the NameFormat regular expression. InitializeModuleSchema func(module string, schema ModuleSchema) error // OnObjectUpdate is called whenever an object is updated in a module's state. This is only called // when logical data is available. It should be assumed that the same data in raw form - // is also passed to OnKVPair. + // is also passed to OnKVPair. Module names must conform to the NameFormat regular expression. OnObjectUpdate func(module string, update ObjectUpdate) error } diff --git a/indexer/base/module_schema.go b/indexer/base/module_schema.go index a93300bfd9..7aa3063413 100644 --- a/indexer/base/module_schema.go +++ b/indexer/base/module_schema.go @@ -1,7 +1,74 @@ package indexerbase +import "fmt" + // ModuleSchema represents the logical schema of a module for purposes of indexing and querying. type ModuleSchema struct { // ObjectTypes describe the types of objects that are part of the module's schema. ObjectTypes []ObjectType } + +// Validate validates the module schema. +func (s ModuleSchema) Validate() error { + for _, objType := range s.ObjectTypes { + if err := objType.Validate(); err != nil { + return err + } + } + + // validate that shared enum types are consistent across object types + enumValueMap := map[string]map[string]bool{} + for _, objType := range s.ObjectTypes { + for _, field := range objType.KeyFields { + err := checkEnum(enumValueMap, field) + if err != nil { + return err + } + } + for _, field := range objType.ValueFields { + err := checkEnum(enumValueMap, field) + if err != nil { + return err + } + } + } + + return nil +} + +func checkEnum(enumValueMap map[string]map[string]bool, field Field) error { + if field.Kind != EnumKind { + return nil + } + + enum := field.EnumDefinition + + if existing, ok := enumValueMap[enum.Name]; ok { + if len(existing) != len(enum.Values) { + return fmt.Errorf("enum %q has different number of values in different object types", enum.Name) + } + + for _, value := range enum.Values { + if !existing[value] { + return fmt.Errorf("enum %q has different values in different object types", enum.Name) + } + } + } else { + valueMap := map[string]bool{} + for _, value := range enum.Values { + valueMap[value] = true + } + enumValueMap[enum.Name] = valueMap + } + return nil +} + +// ValidateObjectUpdate validates that the update conforms to the module schema. +func (s ModuleSchema) ValidateObjectUpdate(update ObjectUpdate) error { + for _, objType := range s.ObjectTypes { + if objType.Name == update.TypeName { + return objType.ValidateObjectUpdate(update) + } + } + return fmt.Errorf("object type %q not found in module schema", update.TypeName) +} diff --git a/indexer/base/module_schema_test.go b/indexer/base/module_schema_test.go new file mode 100644 index 0000000000..a996da3af5 --- /dev/null +++ b/indexer/base/module_schema_test.go @@ -0,0 +1,196 @@ +package indexerbase + +import ( + "strings" + "testing" +) + +func TestModuleSchema_Validate(t *testing.T) { + tests := []struct { + name string + moduleSchema ModuleSchema + errContains string + }{ + { + name: "valid module schema", + moduleSchema: ModuleSchema{ + ObjectTypes: []ObjectType{ + { + Name: "object1", + KeyFields: []Field{ + { + Name: "field1", + Kind: StringKind, + }, + }, + }, + }, + }, + errContains: "", + }, + { + name: "invalid object type", + moduleSchema: ModuleSchema{ + ObjectTypes: []ObjectType{ + { + Name: "", + KeyFields: []Field{ + { + Name: "field1", + Kind: StringKind, + }, + }, + }, + }, + }, + errContains: "invalid object type name", + }, + { + name: "same enum with missing values", + moduleSchema: ModuleSchema{ + ObjectTypes: []ObjectType{ + { + Name: "object1", + KeyFields: []Field{ + { + Name: "k", + Kind: EnumKind, + EnumDefinition: EnumDefinition{ + Name: "enum1", + Values: []string{"a", "b"}, + }, + }, + }, + ValueFields: []Field{ + { + Name: "v", + Kind: EnumKind, + EnumDefinition: EnumDefinition{ + Name: "enum1", + Values: []string{"a", "b", "c"}, + }, + }, + }, + }, + }, + }, + errContains: "different number of values", + }, + { + name: "same enum with different values", + moduleSchema: ModuleSchema{ + ObjectTypes: []ObjectType{ + { + Name: "object1", + KeyFields: []Field{ + { + Name: "k", + Kind: EnumKind, + EnumDefinition: EnumDefinition{ + Name: "enum1", + Values: []string{"a", "b"}, + }, + }, + }, + }, + { + Name: "object2", + KeyFields: []Field{ + { + Name: "k", + Kind: EnumKind, + EnumDefinition: EnumDefinition{ + Name: "enum1", + Values: []string{"a", "c"}, + }, + }, + }, + }, + }, + }, + errContains: "different values", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.moduleSchema.Validate() + if tt.errContains == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } else { + if err == nil || !strings.Contains(err.Error(), tt.errContains) { + t.Fatalf("expected error to contain %q, got: %v", tt.errContains, err) + } + } + }) + } +} + +func TestModuleSchema_ValidateObjectUpdate(t *testing.T) { + tests := []struct { + name string + moduleSchema ModuleSchema + objectUpdate ObjectUpdate + errContains string + }{ + { + name: "valid object update", + moduleSchema: ModuleSchema{ + ObjectTypes: []ObjectType{ + { + Name: "object1", + KeyFields: []Field{ + { + Name: "field1", + Kind: StringKind, + }, + }, + }, + }, + }, + objectUpdate: ObjectUpdate{ + TypeName: "object1", + Key: "abc", + }, + errContains: "", + }, + { + name: "object type not found", + moduleSchema: ModuleSchema{ + ObjectTypes: []ObjectType{ + { + Name: "object1", + KeyFields: []Field{ + { + Name: "field1", + Kind: StringKind, + }, + }, + }, + }, + }, + objectUpdate: ObjectUpdate{ + TypeName: "object2", + Key: "abc", + }, + errContains: "object type \"object2\" not found in module schema", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.moduleSchema.ValidateObjectUpdate(tt.objectUpdate) + if tt.errContains == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } else { + if err == nil || !strings.Contains(err.Error(), tt.errContains) { + t.Fatalf("expected error to contain %q, got: %v", tt.errContains, err) + } + } + }) + } +} diff --git a/indexer/base/name.go b/indexer/base/name.go new file mode 100644 index 0000000000..57e05fa05d --- /dev/null +++ b/indexer/base/name.go @@ -0,0 +1,15 @@ +package indexerbase + +import "regexp" + +// NameFormat is the regular expression that a name must match. +// A name must start with a letter or underscore and can only contain letters, numbers, and underscores. +// A name must be at least one character long and can be at most 64 characters long. +const NameFormat = `^[a-zA-Z_][a-zA-Z0-9_]{0,63}$` + +var nameRegex = regexp.MustCompile(NameFormat) + +// ValidateName checks if the given name is a valid name conforming to NameFormat. +func ValidateName(name string) bool { + return nameRegex.MatchString(name) +} diff --git a/indexer/base/name_test.go b/indexer/base/name_test.go new file mode 100644 index 0000000000..d609ec3c70 --- /dev/null +++ b/indexer/base/name_test.go @@ -0,0 +1,31 @@ +package indexerbase + +import "testing" + +func TestValidateName(t *testing.T) { + tests := []struct { + name string + valid bool + }{ + {"", false}, + {"a", true}, + {"A", true}, + {"_", true}, + {"abc123_def789", true}, + {"0", false}, + {"a0", true}, + {"a_", true}, + {"$a", false}, + {"a b", false}, + {"pretty_unnecessarily_long_but_valid_name", true}, + {"totally_unnecessarily_long_and_invalid_name_sdgkhwersdglkhweriqwery3258", false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if ValidateName(test.name) != test.valid { + t.Errorf("expected %v for name %q", test.valid, test.name) + } + }) + } +} diff --git a/indexer/base/object_type.go b/indexer/base/object_type.go index 73f8721d1d..a8f7dfc688 100644 --- a/indexer/base/object_type.go +++ b/indexer/base/object_type.go @@ -1,17 +1,22 @@ package indexerbase +import "fmt" + // ObjectType describes an object type a module schema. type ObjectType struct { - // Name is the name of the object. + // Name is the name of the object type. It must be unique within the module schema + // and conform to the NameFormat regular expression. Name string // 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. + // a singleton and only has one value. Field names must be unique within the + // object between both key and value fields. KeyFields []Field // 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 // RetainDeletions is a flag that indicates whether the indexer should retain @@ -21,3 +26,56 @@ type ObjectType struct { // the option of retaining such data and distinguishing from other "true" deletions. RetainDeletions bool } + +// Validate validates the object type. +func (o ObjectType) Validate() error { + if !ValidateName(o.Name) { + return fmt.Errorf("invalid object type name %q", o.Name) + } + + fieldNames := map[string]bool{} + for _, field := range o.KeyFields { + if err := field.Validate(); err != nil { + return fmt.Errorf("invalid key field %q: %w", field.Name, err) + } + + if fieldNames[field.Name] { + return fmt.Errorf("duplicate field name %q", field.Name) + } + fieldNames[field.Name] = true + } + + for _, field := range o.ValueFields { + if err := field.Validate(); err != nil { + return fmt.Errorf("invalid value field %q: %w", field.Name, err) + } + + if fieldNames[field.Name] { + return fmt.Errorf("duplicate field name %q", field.Name) + } + fieldNames[field.Name] = true + } + + if len(o.KeyFields) == 0 && len(o.ValueFields) == 0 { + return fmt.Errorf("object type %q has no key or value fields", o.Name) + } + + return nil +} + +// ValidateObjectUpdate validates that the update conforms to the object type. +func (o ObjectType) ValidateObjectUpdate(update ObjectUpdate) error { + if o.Name != update.TypeName { + return fmt.Errorf("object type name %q does not match update type name %q", o.Name, update.TypeName) + } + + if err := ValidateForKeyFields(o.KeyFields, update.Key); err != nil { + return fmt.Errorf("invalid key for object type %q: %w", update.TypeName, err) + } + + if update.Delete { + return nil + } + + return ValidateForValueFields(o.ValueFields, update.Value) +} diff --git a/indexer/base/object_type_test.go b/indexer/base/object_type_test.go new file mode 100644 index 0000000000..c3df08778d --- /dev/null +++ b/indexer/base/object_type_test.go @@ -0,0 +1,229 @@ +package indexerbase + +import ( + "strings" + "testing" +) + +var object1Type = ObjectType{ + Name: "object1", + KeyFields: []Field{ + { + Name: "field1", + Kind: StringKind, + }, + }, +} + +var object2Type = ObjectType{ + KeyFields: []Field{ + { + Name: "field1", + Kind: StringKind, + }, + { + Name: "field2", + Kind: Int32Kind, + }, + }, +} + +var object3Type = ObjectType{ + Name: "object3", + ValueFields: []Field{ + { + Name: "field1", + Kind: StringKind, + }, + { + Name: "field2", + Kind: Int32Kind, + }, + }, +} + +var object4Type = ObjectType{ + Name: "object4", + KeyFields: []Field{ + { + Name: "field1", + Kind: Int32Kind, + }, + }, + ValueFields: []Field{ + { + Name: "field2", + Kind: StringKind, + }, + }, +} + +func TestObjectType_Validate(t *testing.T) { + tests := []struct { + name string + objectType ObjectType + errContains string + }{ + { + name: "valid object type", + objectType: object1Type, + errContains: "", + }, + { + name: "empty object type name", + objectType: ObjectType{ + Name: "", + KeyFields: []Field{ + { + Name: "field1", + Kind: StringKind, + }, + }, + }, + errContains: "invalid object type name", + }, + { + name: "invalid key field", + objectType: ObjectType{ + Name: "object1", + KeyFields: []Field{ + { + Name: "", + Kind: StringKind, + }, + }, + }, + errContains: "invalid field name", + }, + { + name: "invalid value field", + objectType: ObjectType{ + Name: "object1", + ValueFields: []Field{ + { + Kind: StringKind, + }, + }, + }, + errContains: "invalid field name", + }, + { + name: "no fields", + objectType: ObjectType{Name: "object0"}, + errContains: "has no key or value fields", + }, + { + name: "duplicate field", + objectType: ObjectType{ + Name: "object1", + KeyFields: []Field{ + { + Name: "field1", + Kind: StringKind, + }, + }, + ValueFields: []Field{ + { + Name: "field1", + Kind: StringKind, + }, + }, + }, + errContains: "duplicate field name", + }, + { + name: "duplicate field 22", + objectType: ObjectType{ + Name: "object1", + KeyFields: []Field{ + { + Name: "field1", + Kind: StringKind, + }, + { + Name: "field1", + Kind: StringKind, + }, + }, + }, + errContains: "duplicate field name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.objectType.Validate() + if tt.errContains == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } else { + if err == nil || !strings.Contains(err.Error(), tt.errContains) { + t.Fatalf("expected error to contain %q, got: %v", tt.errContains, err) + } + } + }) + } +} + +func TestObjectType_ValidateObjectUpdate(t *testing.T) { + tests := []struct { + name string + objectType ObjectType + object ObjectUpdate + errContains string + }{ + { + name: "wrong name", + objectType: object1Type, + object: ObjectUpdate{ + TypeName: "object2", + Key: "hello", + }, + errContains: "does not match update type name", + }, + { + name: "invalid value", + objectType: object1Type, + object: ObjectUpdate{ + TypeName: "object1", + Key: 123, + }, + errContains: "invalid value", + }, + { + name: "valid update", + objectType: object4Type, + object: ObjectUpdate{ + TypeName: "object4", + Key: int32(123), + Value: "hello", + }, + }, + { + name: "valid deletion", + objectType: object4Type, + object: ObjectUpdate{ + TypeName: "object4", + Key: int32(123), + Value: "ignored!", + Delete: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.objectType.ValidateObjectUpdate(tt.object) + if tt.errContains == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } else { + if err == nil || !strings.Contains(err.Error(), tt.errContains) { + t.Fatalf("expected error to contain %q, got: %v", tt.errContains, err) + } + } + }) + } +} diff --git a/indexer/base/object_update.go b/indexer/base/object_update.go index 05b5962930..73e9c5ce4e 100644 --- a/indexer/base/object_update.go +++ b/indexer/base/object_update.go @@ -1,5 +1,7 @@ package indexerbase +import "sort" + // ObjectUpdate represents an update operation on an object in a module's state. type ObjectUpdate struct { // TypeName is the name of the object type in the module's schema. @@ -38,3 +40,22 @@ type ValueUpdates interface { // it was unable to decode the values properly (which could be the case in lazy evaluation). Iterate(func(col string, value interface{}) bool) error } + +// MapValueUpdates is a map-based implementation of ValueUpdates which always iterates +// over keys in sorted order. +type MapValueUpdates map[string]interface{} + +// Iterate implements the ValueUpdates interface. +func (m MapValueUpdates) Iterate(fn func(col string, value interface{}) bool) error { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if !fn(k, m[k]) { + return nil + } + } + return nil +} diff --git a/indexer/base/object_update_test.go b/indexer/base/object_update_test.go new file mode 100644 index 0000000000..bfd6fbb984 --- /dev/null +++ b/indexer/base/object_update_test.go @@ -0,0 +1,52 @@ +package indexerbase + +import "testing" + +func TestMapValueUpdates_Iterate(t *testing.T) { + updates := MapValueUpdates(map[string]interface{}{ + "a": "abc", + "b": 123, + }) + + got := map[string]interface{}{} + err := updates.Iterate(func(fieldname string, value interface{}) bool { + got[fieldname] = value + return true + }) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if len(got) != 2 { + t.Errorf("expected 2 updates, got: %v", got) + } + + if got["a"] != "abc" { + t.Errorf("expected a=abc, got: %v", got) + } + + if got["b"] != 123 { + t.Errorf("expected b=123, got: %v", got) + } + + got = map[string]interface{}{} + err = updates.Iterate(func(fieldname string, value interface{}) bool { + if len(got) == 1 { + return false + } + got[fieldname] = value + return true + }) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if len(got) != 1 { + t.Errorf("expected 1 updates, got: %v", got) + } + + // should have gotten the first field in order + if got["a"] != "abc" { + t.Errorf("expected a=abc, got: %v", got) + } +}