Skip to content

Commit

Permalink
Any map list validation (#110)
Browse files Browse the repository at this point in the history
* Added improved validation for maps and lists

Allows schemas in lists and maps, ensures that values are valid values, and ensures that values are homogeneous when necessary

* Fix linter errors and reduce function sizes

* Improve test and addressed review comments

* Fix linting error
  • Loading branch information
jaredoconnell authored Dec 12, 2024
1 parent d6d28c4 commit df66863
Show file tree
Hide file tree
Showing 2 changed files with 253 additions and 35 deletions.
152 changes: 140 additions & 12 deletions schema/any.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,8 @@ func (a *AnySchema) Unserialize(data any) (any, error) {
return a.checkAndConvert(data)
}

//nolint:funlen
func (a *AnySchema) ValidateCompatibility(typeOrData any) error {
// Check if it's a schema.Type. If it is, verify it. If not, verify it as data.
schemaType, ok := typeOrData.(Type)
if !ok {
_, err := a.Unserialize(typeOrData)
return err
}

switch schemaType.ReflectedType().Kind() {
func (a *AnySchema) validateSchemaCompatibility(schema Type) error {
switch schema.ReflectedType().Kind() {
case reflect.Int:
fallthrough
case reflect.Uint:
Expand Down Expand Up @@ -69,16 +61,152 @@ func (a *AnySchema) ValidateCompatibility(typeOrData any) error {
default:
// Schema is not a primitive, slice, or map type, so check the complex types
// Explicitly allow object schemas since their reflected type can be a struct if they are struct mapped.
switch typeOrData.(type) {
switch schema.(type) {
case *AnySchema, *OneOfSchema[int64], *OneOfSchema[string], *ObjectSchema:
// These are the allowed values.
default:
// It's not an any schema or a type compatible with an any schema, so error
return &ConstraintError{
Message: fmt.Sprintf("schema type `%T` cannot be used as an input for an 'any' type", typeOrData),
Message: fmt.Sprintf("schema type `%T` cannot be used as an input for an 'any' type", schema),
}
}
return nil
}
}

func (a *AnySchema) validateAnyMap(data map[any]any) error {
// Test individual values
var firstReflectKind reflect.Kind
for key, value := range data {
// Validate key
reflectKind := reflect.ValueOf(key).Kind()
switch reflectKind {
// While it is possible to add more types of ints, it's likely better to keep it consistent with i64
case reflect.Int64:
fallthrough
case reflect.String:
// Valid type
default:
return &ConstraintError{
Message: fmt.Sprintf("invalid key type for map passed into 'any' type (%s); should be string or i64", reflectKind),
}
}
if firstReflectKind == reflect.Invalid { // First item
firstReflectKind = reflectKind
} else if firstReflectKind != reflectKind {
return &ConstraintError{
Message: fmt.Sprintf(
"mismatched key types in map passed into 'any' type: %s != %s",
firstReflectKind, reflectKind),
}
}

// Validate value
err := a.ValidateCompatibility(value)
if err != nil {
return &ConstraintError{
Message: fmt.Sprintf("validation error while validating any-keyed map item item %q of map for 'any' type (%s)", key, err.Error()),
}
}
}
return nil
}

//nolint:nestif
func (a *AnySchema) validateAnyList(data []any) error {
if len(data) == 0 {
return nil // No items to check, and following code assumes non-empty list
}
// Test list items
for _, item := range data {
err := a.ValidateCompatibility(item)
if err != nil {
return &ConstraintError{
Message: fmt.Sprintf("validation error while validating list item of type `%T` in any type (%s)", item, err.Error()),
}
}
}
// validate that all list items are compatible with the first to make the list homogeneous.
firstItem := data[0]
firstItemType, firstValIsSchema := firstItem.(Type)
if firstValIsSchema {
for i := 1; i < len(data); i++ {
valToTest := data[i]
err := firstItemType.ValidateCompatibility(valToTest)
if err != nil {
return &ConstraintError{
Message: fmt.Sprintf(
"validation error while validating for homogeneous list item of type `%T` in any type (%s)",
valToTest, err.Error()),
}
}
}
} else {
// Loop through all items. Ensure they have the same type.
firstItemType := reflect.ValueOf(firstItem).Kind()
for i := 1; i < len(data); i++ {
valToTest := data[i]
typeToTest := reflect.ValueOf(valToTest).Kind()
if firstItemType != typeToTest {
// Not compatible or is a schema
schemaType, valIsSchema := valToTest.(Type)
if !valIsSchema {
return &ConstraintError{
Message: fmt.Sprintf(
"types do not match between list items passed for any type %T != %T; "+
"lists should have homogeneous types",
firstItem, valToTest),
}
} else {
err := schemaType.ValidateCompatibility(valToTest)
if err != nil {
return &ConstraintError{
Message: fmt.Sprintf(
"types do not match between list items passed for any type %s; "+
"lists should have homogeneous types",
err),
}
}
}
}
}
}
return nil
}

func (a *AnySchema) ValidateCompatibility(typeOrData any) error {
switch typeOrData := typeOrData.(type) {
case Type:
return a.validateSchemaCompatibility(typeOrData)
case map[string]any:
// Test individual values
for key, value := range typeOrData {
err := a.ValidateCompatibility(value)
if err != nil {
return &ConstraintError{
Message: fmt.Sprintf("validation error while validating string-keyed map item item %q of map for 'any' type (%s)", key, err.Error()),
}
}
}
return nil
case map[int64]any:
// Test individual values
for key, value := range typeOrData {
err := a.ValidateCompatibility(value)
if err != nil {
return &ConstraintError{
Message: fmt.Sprintf("validation error while validating int-keyed map item item %q of map for 'any' type (%s)", key, err.Error()),
}
}
}
return nil
case map[any]any:
return a.validateAnyMap(typeOrData)
case []interface{}:
return a.validateAnyList(typeOrData)
default:
_, err := a.Unserialize(typeOrData)
return err
}
}

Expand Down
136 changes: 113 additions & 23 deletions schema/any_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,40 +152,38 @@ func TestAnyTypeReflectedType(t *testing.T) {
assert.NotNil(t, a.ReflectedType())
}

func TestAnyValidateCompatibility(t *testing.T) {
s1 := schema.NewAnySchema()
properties := map[string]*schema.PropertySchema{
"field1": schema.NewPropertySchema(
schema.NewIntSchema(nil, nil, nil),
nil,
true,
nil,
nil,
nil,
nil,
nil,
),
}
type someStruct struct {
//nolint:unused // This is just for test purposes.
field1 int
}
objectSchema := schema.NewObjectSchema("some-id", properties)
structMappedObjectSchema := schema.NewStructMappedObjectSchema[someStruct]("some-id", properties)
var properties = map[string]*schema.PropertySchema{
"field1": schema.NewPropertySchema(
schema.NewIntSchema(nil, nil, nil),
nil,
true,
nil,
nil,
nil,
nil,
nil,
),
}

type someStruct struct {
field1 int
}

var objectSchema = schema.NewObjectSchema("some-id", properties)
var structMappedObjectSchema = schema.NewStructMappedObjectSchema[someStruct]("some-id", properties)

func TestAnyValidateCompatibilitySimple(t *testing.T) {
s1 := schema.NewAnySchema()
assert.NoError(t, s1.ValidateCompatibility(schema.NewAnySchema()))
assert.NoError(t, s1.ValidateCompatibility(schema.NewStringSchema(nil, nil, nil)))
assert.NoError(t, s1.ValidateCompatibility(schema.NewIntSchema(nil, nil, nil)))
assert.NoError(t, s1.ValidateCompatibility(schema.NewBoolSchema()))
assert.NoError(t, s1.ValidateCompatibility(schema.NewListSchema(schema.NewBoolSchema(), nil, nil)))
assert.NoError(t, s1.ValidateCompatibility(schema.NewFloatSchema(nil, nil, nil)))
assert.Error(t, s1.ValidateCompatibility(schema.NewDisplayValue(nil, nil, nil)))
assert.NoError(t, s1.ValidateCompatibility("test"))
assert.NoError(t, s1.ValidateCompatibility(1))
assert.NoError(t, s1.ValidateCompatibility(1.5))
assert.NoError(t, s1.ValidateCompatibility(true))
assert.NoError(t, s1.ValidateCompatibility([]string{}))
assert.NoError(t, s1.ValidateCompatibility(map[string]any{}))
assert.NoError(t, s1.ValidateCompatibility(schema.NewStringEnumSchema(map[string]*schema.DisplayValue{})))
assert.NoError(t, s1.ValidateCompatibility(schema.NewIntEnumSchema(map[int64]*schema.DisplayValue{}, nil)))
assert.NoError(t, s1.ValidateCompatibility(objectSchema))
Expand All @@ -197,4 +195,96 @@ func TestAnyValidateCompatibility(t *testing.T) {
assert.NoError(t, s1.ValidateCompatibility(
schema.NewOneOfIntSchema[int64](map[int64]schema.Object{}, "id", false),
))

}

func TestAnyValidateCompatibilityLists(t *testing.T) {
s1 := schema.NewAnySchema()
assert.NoError(t, s1.ValidateCompatibility(schema.NewListSchema(schema.NewBoolSchema(), nil, nil)))
assert.NoError(t, s1.ValidateCompatibility([]string{}))

// Test non-homogeneous list
err := s1.ValidateCompatibility([]any{
int64(5),
"5",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "homogeneous")
// Test a list of object schemas
assert.NoError(t, s1.ValidateCompatibility([]any{
structMappedObjectSchema,
}))
}

//nolint:funlen
func TestAnyValidateCompatibilityMaps(t *testing.T) {
// Test custom maps with schemas and data
s1 := schema.NewAnySchema()
assert.NoError(t, s1.ValidateCompatibility(map[string]any{}))
// Include invalid item within an any map
err := s1.ValidateCompatibility(map[any]any{
"b": someStruct{field1: 1},
})
assert.Error(t, err)
// Include invalid item within a string map
err = s1.ValidateCompatibility(map[string]any{
"b": someStruct{field1: 1},
})
assert.Error(t, err)
assert.Contains(t, err.Error(), `"b"`) // Identifies the problematic key
assert.Contains(t, err.Error(), "someStruct") // Identifies the problematic type
// String key type
assert.NoError(t, s1.ValidateCompatibility(map[string]any{
"a": true,
"b": "test",
"c": []any{
structMappedObjectSchema,
},
"d": structMappedObjectSchema,
"e": schema.NewStringSchema(nil, nil, nil),
}))
// int key type
assert.NoError(t, s1.ValidateCompatibility(map[int64]any{
1: true,
2: "test",
3: []any{
structMappedObjectSchema,
},
4: structMappedObjectSchema,
5: schema.NewStringSchema(nil, nil, nil),
}))
// any key type with string key values
assert.NoError(t, s1.ValidateCompatibility(map[any]any{
"a": true,
"b": "test",
"c": []any{
structMappedObjectSchema,
},
"d": structMappedObjectSchema,
"e": schema.NewStringSchema(nil, nil, nil),
}))
// any key type with integer key values
assert.NoError(t, s1.ValidateCompatibility(map[any]any{
int64(1): true,
int64(2): "test",
int64(3): []any{
structMappedObjectSchema,
},
int64(4): structMappedObjectSchema,
int64(5): schema.NewStringSchema(nil, nil, nil),
}))
// any key type with mixed key values
err = s1.ValidateCompatibility(map[any]any{
"a": true,
int64(2): "test",
int64(3): []any{
structMappedObjectSchema,
},
int64(4): structMappedObjectSchema,
int64(5): schema.NewStringSchema(nil, nil, nil),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "mismatched")
assert.Contains(t, err.Error(), "string")
assert.Contains(t, err.Error(), "int64")
}

0 comments on commit df66863

Please sign in to comment.