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.