feat(indexer/base): schema and value validation (#20665)
This commit is contained in:
+38
-2
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user