Dry Schema/Validation: Array of multiple custom types -- is it possible?

Hi there.

I’m pretty new to the Dry set of libraries (have been exploring the docs/playing around with it during the last few days and started on a basic POC). Just to provide some background to my use-case, I maintain a Gem that wraps a REST API, and want to add a validation layer to the gem. Dry Schema/Validation seems like great libraries and I think they could be a good fit for what I want to do. I have most of it worked out, but have hit a bit of a dead-end with one specific part of the implementation – arrays of custom types.

A somewhat contrived example for the type of data that I need to validate might look something like this:

person = {
  name: 'Bob',
  age: 19,
  contact_details: { 
    email: 'bob@example.org',
    phone: '1234567890',
    address: { 
      street: '123 Main St',
      city: 'Anytown',
      zipcode: '12345' 
    },
    favorite_things: [
      { type: 'Color', name: 'red', code: 1 },
      { type: 'Fruit', name: 'apple' },
      { type: 'Book', title: 'The Great Gatsby', genre: 'Fiction' }
    ]
  }
}

Most of the validation objects and schemas for this are fairly straightforward, but I’m struggling with the array of hashes. I thought I could perhaps use custom types for the various Hash objects, but validation only seems to work if the array is of a single custom type, not multiple custom types. However, multiple standard types does validate correctly.

Examples

Array of multiple standard types (e.g. Integer, String) works as expected.

require 'dry-validation'
require 'dry-schema'

class PersonContract < Dry::Validation::Contract
  schema do
    required(:name).filled(:string)
    required(:age).value(:integer)
    optional(:favorite_things).value(array[Types::Integer | Types::String])
  end
end

# Valid inputs:
valid_person = {
  name: 'Bob',
  age: 19,
  favorite_things: [ 
    1,
    'hello'
  ]
}

valid_person_result = person_contract.call(valid_person)
# valid_person_result.success? => true

# Inalid inputs:
invalid_person = {
  name: 'Bob',
  age: 19,
  favorite_things: [ 
    1,
    'hello',
    :foo  # This should fail validation since :foo is not an Integer or String
  ]
}

invalid_person_result = person_contract.call(invalid_person)
# invalid_person_result.success? => false
# invalid_person_result.errors.to_h
# => {favorite_things: {2 => ["must be an integer or must be a string"]}}

Array of single custom type works as expected

require 'dry-validation'
require 'dry-schema'
require 'dry-types'

Types = Dry.Types()

Color = Types::Hash.schema(type: Types::String, name: Types::String, code: Types::Integer)

class PersonContract < Dry::Validation::Contract
  schema do
    required(:name).filled(:string)
    required(:age).value(:integer)
    optional(:favorite_things).value(array[Color])
  end
end

person_contract = PersonContract.new

# Valid inputs:
valid_person = {
  name: 'Bob',
  age: 19,
  favorite_things: [ 
    { type: 'Color', name: 'red', code: 1 },
    { type: 'Color', name: 'blue', code: 2 }
  ]
}

valid_person_result = person_contract.call(valid_person)
# valid_person_result.success? => true

# Inalid inputs:
invalid_person = {
  name: 'Bob',
  age: 19,
  favorite_things: [ 
    { type: 'Color', name: 'red', code: 1 },
    { type: 'Color', name: 'blue' },  # This should fail validation since it is missing the code key
    { type: 'Color', name: 123 } # This should fail validation since name is not a String and it is missing the code key
  ]
}

invalid_person_result = person_contract.call(invalid_person)
# invalid_person_result.success? => false
# invalid_person_result.errors.to_h
# => {favorite_things: {1 => {code: ["is missing"]}, 2 => {name: ["must be a string"], code: ["is missing"]}}}

Array of multiple custom type does not work as expected

require 'dry-validation'
require 'dry-schema'
require 'dry-types'

Types = Dry.Types()

Color = Types::Hash.schema(type: Types::String, name: Types::String, code: Types::Integer)
Book = Types::Hash.schema(type: Types::String, title: Types::String, genre: Types::String)

class PersonContract < Dry::Validation::Contract
  schema do
    required(:name).filled(:string)
    required(:age).value(:integer)
    optional(:favorite_things).value(array[Color | Book])
  end
end

person_contract = PersonContract.new

# Valid inputs:
valid_person = {
  name: 'Bob',
  age: 19,
  favorite_things: [ 
    { type: 'Color', name: 'red', code: 1 },
    { type: 'Book', title: 'The Great Gatsby', genre: 'Fiction' }
  ]
}

valid_person_result = person_contract.call(valid_person)
# valid_person_result.success? => true

# Inalid inputs:
invalid_person = {
  name: 'Bob',
  age: 19,
  favorite_things: [ 
    { type: 'Color', name: 'red', code: 1 },
    { type: 'Book', title: 'The Great Gatsby', genre: 'Fiction' },
    { type: 'Foo', foo: 'bar' } # This should fail validation since it does not match either Color or Book types
  ]
}

invalid_person_result = person_contract.call(invalid_person)
# invalid_person_result.success? => true
# invalid_person_result.errors.to_h
# => {}

I’m not sure if this is due to:

  1. User error: I’m doing something wrong in my implementation
  2. A limitation of the library for which I will need to find a custom work-around
  3. A bug

If it’s 1, I would very much appreciate some guidance from those more experienced with the Dry libraries as to where I am going wrong.

If it’s 2, I’m wondering if anyone has come up against this issue before, and if so how did you get around it? I’m thinking I might need to write a custom rule in a validation specifically for dealing with this type of array, or maybe look at defining some sort of macro (though I’ve not explored macros deeply yet, so this is just guesswork).

If it’s 3, please let me know. I’d be happy to open an issue on the GitHub repo for a bug report.

Thanks in advance.

Karl

Hi @superchilled. Happy to see you trying out Dry ecosystem! And thanks for a thorough report.

What you described most certainly sounds like a bug, worth reporting on GitHub in my opinion.

However, I would like to point out one thing. Even if it was working as you expect, it would not stop the input like this to be passed as valid:

person = {
  name: 'Bob',
  age: 19,
  favorite_things: [ 
    { type: 'Book', name: 'red', code: 1 },
    { type: 'Color', title: 'The Great Gatsby', genre: 'Fiction' },
  ]
}

This is because schema only checks structural correctness - in this case the presence of keys. One way to work around that is to use a rule to check each element of favorite_things. Something like this:

class PersonContract < Dry::Validation::Contract
  schema do
    required(:name).filled(:string)
    required(:age).value(:integer)
    optional(:favorite_things).value(array[Types::Hash])
  end

  rule(:favorite_things).each do
    is_valid = case value[:type]
      when "Color" then Color.valid?(value)
      when "Book" then Book.valid?(value)
      else false
    end

    key.failure("incorrect type") unless is_valid 
  end
end

and it actually works for your case, so I think it could be considered a workaround for now.

#<Dry::Validation::Result{name: "Bob", age: 19, favorite_things: [{type: "Color", name: "red", code: 1}, {type: "Book", title: "The Great Gatsby", genre: "Fiction"}, {type: "Foo", foo: "bar"}]} errors={favorite_things: {2 => ["incorrect type"]}}>
false
{favorite_things: {2 => ["incorrect type"]}}

Hi @katafrakt.

Thanks so much for your super-speedy and helpful response! :slight_smile:

I think the rule you’ve suggested is definitely the way to go with this. (I was kind of thinking along similar lines anyway, but it’s great to have that idea confirmed).

I’ll open an issue on the GitHub repo for this in any case, but I think with your suggested solution I should be able to move forward with the implementation of my POC.

Thanks again!

Karl