feat(indexer/base): schema and value validation (#20665)

This commit is contained in:
Aaron Craelius
2024-06-20 16:14:00 +00:00
committed by GitHub
parent 88da9eff4f
commit a6407f411e
17 changed files with 1815 additions and 20 deletions
+38 -2
View File
@@ -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)
}
+106
View File
@@ -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)
}
}
})
}
}
+59 -1
View File
@@ -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
}
+183
View File
@@ -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)
}
}
})
}
}
+71
View File
@@ -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
}
+143
View File
@@ -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)
}
}
})
}
}
+280 -13
View File
@@ -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
}
}
+261
View File
@@ -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)
}
}
})
}
}
+3 -2
View File
@@ -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
}
+67
View File
@@ -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)
}
+196
View File
@@ -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)
}
}
})
}
}
+15
View File
@@ -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)
}
+31
View File
@@ -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)
}
})
}
}
+60 -2
View File
@@ -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)
}
+229
View File
@@ -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)
}
}
})
}
}
+21
View File
@@ -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
}
+52
View File
@@ -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)
}
}