Coercing classes that respond to to_h

Hi,

Is there a way to coerce an input class instance that responds to to_h into an actual hash before running validations?

Say I have an attribute that is being verified by a .hash(NestedSchema) and that, in turn, contains an attribute and verifies it with .hash(DeeperNestedSchema). Input arguments to these attributes are long and complex instances of different classes (they come from the “outside world” and are not regular Rails params). These classes, among other things, “know” how to be normalized into a hash (responds to to_h message). Is there a way to convert these instances into hashes and then operate on that?

Otherwise validations do work, but coerced params contain the same input instances of classes and not coerced hashes that can be used to create / update data.

So the idea is something like this:

class ParentSchema < Dry::Validation::Contract
  required(:user)hash(:to_h, ChildSchema)
end

ChildSchema = Dry::Schema.Params do
  required(:post).hash(:to_h, PostSchema)
end

And not when I say ParentSchema.new.(@instance_of_some_class) I get its to_h version (which is just a hash of validated / parsed / filtered attributes).

Thanks!

This should work

module Types
  include Dry.Types()

  Coercible::Hash = Nominal::Hash.constructor(&:to_h)
end

ChildSchema = Dry::Schema.Params do
  required(:post).type(Types::Coercible::Hash).schema(PostSchema)
end

Or you can build a more safe constructor type:

module Types
  include Dry.Types()

  Coercible::Hash = Nominal::Hash.constructor do |input, &if_failed|
    if input.respond_to?(:to_h)
      input.to_h
    else
      if_failed.(input)
    end
  end
end

Thanks a lot, @flash-gordon, that’s exactly what I needed. How would I apply that type conversion to an array of hashes where each on of those hashes needs to be converted first?

Tried specifying type for some attributes, but the to_h message on the result object still returns original instances of incoming classes.

Here’s my contract:

module UserSchema
   class Create < BaseSchema
     json do
        required(:user_attributes)
          .type(CustomTypes::Coercible::Hash)
          .schema(UserSchema)
       required(:account_attributes)
          .type(CustomTypes::Coercible::Hash)
          .schema(AccountSchema)
     end

     rule(:user_attributes).validate(:password_match)
  end
end

And here’s my BaseSchema

class BaseSchema < Dry::Validation::Contract
  register_macro(:password_match) do
    unless values[:password].eql?(values[:confirm_password])
      key.failure('password does not match password confirmation.')
    end
  end
end

module CustomTypes
  include Dry.Types

  Coercible::Hash = Nominal::Hash.constructor(&:to_h)
end

So user_attributes and account_attributes are those large instances of different classes that respond to to_h message.

Update: tried defining my own simple class with to_h on it and pass it as an argument like this required(:simple_attributes).type(CustomTypes::Coercible::Hash).hash(SimpleAttributesSchema). Errors returned are { "simple_attributes": ["must be a hash"] }.

So I created a brand new contrived example:

# Custom type
module CustomTypes
  include Dry.Types()

  Person = Nominal::Any.constructor(&:to_h)
end

# Person class
class Person
  def to_h
    { name: 'Luke', age: 21 }
  end
end

# Person schema
PersonSchema = Dry::Schema.Params do
  required(:name).filled(:string)
  required(:age).value(:integer, gt?: 18)
end

# Contract
class NewUserContract < Dry::Validation::Contract
  params do
    required(:person).type(CustomTypes::Person).schema(PersonSchema)
  end
end

Then,

luke = Person.new

contract = NewUserContract.new

contract.call(person: luke)

undefined method `key?' for #<Person:0x00007fdd48873390>

But,

luke = Person.new

contract = NewUserContract.new

contract.call(person: luke.to_h)

#<Dry::Validation::Result{:person=>{:name=>"Luke", :age=>21}} errors={}>

So the question why type coercion in the contract definition is not respected remains.

Please post the last repro as a bug in dry-schema, it should have worked.

Thanks, @flash-gordon, I submitted the issue.

Where is this if_failed argument documented? I think I need this functionality, but I only found it by randomly reading forum posts.