I’ve been trying to convert some active record validations to dry-validations 1.x recently and I’ve hit a couple of problems.
Sometimes I want to call a contract on the input conditionally and it’s not obvious (at least to me) how to do it (it is more obvious how to call a schema conditionally).
Sometimes I want to call a contract on nested data, and have the contract’s rules applied to that data as if it had no knowledge of its surrounding context.
My understanding of dry-validations 1.0 is that this is mostly to do with the design decision to validate params (as data) first, then run rules second. I totally agree with this design decision as it makes rule writing much easier.
After trying a few different ways, I decided to hack something completely different together, to explore some ideas. My idea is to have a higher level validation composer, which can specify other contracts to be used on the data, with simple guard clauses.
Here’s some example code from a project:
class OrderContract < ApplicationContract
params do
required(:delivery_required).value(:bool)
required(:delivery_address_same).value(:bool)
required(:accept_terms).value(:bool)
end
rule(:accept_terms).validate(:acceptance)
# this is the new bit - it runs after above schema/rules
# result values are available to the guard clauses
compose do
contract CustomerContract
contract address: AddressContract
guard :require_delivery_address? do
contract delivery_address: AddressContract
end
register_guard :require_delivery_address? do
values[:delivery_required] && !values[:delivery_address_same]
end
end
end
You can also create a standalone Composed contract, an example from the specs:
describe ApplicationContract::Composition do
let(:composition) do
ApplicationContract::Composition.new do |c|
c.contract email_schema
c.contract name_schema
end
end
describe "#call(input)" do
subject(:result) { composition.call(input) }
let(:input) { { email: "foo", name: "jim", other: "foo" } }
it "returns a result" do
expect(subject).to be_failure
end
it "returns the schema values" do
expect(result.to_h).to eq(email: 'foo', name: 'jim')
end
it "has errors" do
expect(result.errors.to_h).to eq(email: ['is in invalid format'])
end
end
end
If anyone’s interested in this, and how it could be better, or if it is suitable for adding to dry-validation, I’d be glad to discuss, and also post the code that makes the above work.
I’m finding it has solved the problems 1 & 2 above pretty well. In order to come up with a solution I’ve added two new result classes ResultAtPath and ResultSet, which aid in composition
The composable contract returns a result duck type, which is composed of the contract results
The DSL now looks like this:
# expects hash of order data, customer data,
# nested :address, and possibly a nested :delivery_address
class OrderContract < ApplicationContract
params do
required(:delivery_required).value(:bool)
required(:delivery_address_same).value(:bool)
required(:accept_terms).value(:bool)
end
rule(:accept_terms).validate(:acceptance)
compose do
contract CustomerContract
path :address do
contract AddressContract
end
path :delivery_address do
contract AddressContract, check: :require_delivery_address?
end
register_check :require_delivery_address? do
values[:delivery_required] && !values[:delivery_address_same]
end
end
end
@i2w I’m very happy that you worked on this because I’ve been thinking about something very similar! I would probably vote for expanding Contract’s DSL rather than introducing a new concept on top of it though. WDYT? I’d be on board with adding this as an experimental new feature in one of the minor releases with the goal to ship its final version in 2.0.0.
Hi @solnic, I’m very happy to modify the above to add to the DSL. My reason for making this spike separate was just that it’s easier for me to understand the moving parts. I must say that the API for validation/errors has been a joy to work with when creating this spike.
In terms of a suggested API, how about we remove the enclosing ‘compose’ block, so we have something like:
class OrderContract < ApplicationContract
params do
required(:delivery_required).value(:bool)
required(:delivery_address_same).value(:bool)
required(:accept_terms).value(:bool)
end
rule(:accept_terms).validate(:acceptance)
# also calls the CustomerContract on the input
contract CustomerContract
# also calls the AddressContract on the input at :path
path :address do
contract AddressContract
end
# or
contract AddressContract, path: :address
# if the check is true, calls the AddressContract on the input at :delivery_address
check :validate_delivery_address? do
contract AddressContract, path: :delivery_address
end
# these should be inherited, in much the same way as rule macros.
# a check is different from a rule in that its job is to return true or false, it adds no errors.
# its purpose is to facilitate application of contracts to some or all of the input, conditional
# on other parts of the input.
register_check :validate_delivery_address? do
# this context has access to values and also options for external dependencies
values[:delivery_required] && !values[:delivery_address_same]
end
end
The main new bit of code is the concept of a ResultSet, which quacks like a result, but can composed of Results, optionally mounted at paths (love the Path API btw).
So, next steps?
[ ] Decide on reasonable API starting point
[ ] Add specs and code to ianwhite/dry-validation for the new behaviour
[ ] Go a bit deeper into the desired semantics for ResultSet, and how it should behave in cases of key clashes (ie should we deep merge?, etc)
Let’s maybe focus on the contract composition first and deal with the check feature separately later. Could you tell me what would be the benefit of having that extra path method? Wouldn’t contract be enough?
If you mean the path method vs path option, there is no difference, it’s just syntactic sugar.
Re: the path option TLDR; the benefit is that it will enable re-use and reduce coupling of rules to input paths.
In more detail the benefit is that you will be able to re-use rules in much the same way that you can currently re-use schemas. Simple rules which only have dependencies on the #key or #value methods can be re-used. But complicated rules which refer to the #values method cannot be reused in different nested contexts. By enabling contracts to be ‘mounted’ at different paths on the input, those contracts can be written in a more general way.
require 'dry/validation'
# enter two numbers, to guess the rule (their sum must be even)
class EvenContract < Dry::Validation::Contract
params do
required(:arg1).filled(:integer)
required(:arg2).filled(:integer)
end
rule(:arg1, :arg2) do
key(:arg2).failure('is not suitable') unless (values[:arg1] + values[:arg2]).even?
end
end
input = { arg1: "1", arg2: "2" }
EvenContract.new.call(input).errors.to_h # => {:arg2=>["is not suitable"]}
# How would we be able to process this input only using dry-validation, and without re-writing the rules?
guesses_input = { guess1: { arg1: "1", arg2: "2" }, guess2: { arg1: "2", arg2: "4" } }
# With the code spike here https://gist.github.com/ianwhite/a32fcd439020ea07ed1fe3243152274f
# we can do this: (this is ct working code)
guesses_contract = ComposableContract.compose do
contract EvenContract, path: :guess1
contract EvenContract, path: :guess2
end
result = guesses_contract.call(guesses_input) #<ComposableContract::ResultSet:0x00007f9fb97a17e0 @success=false, @message_set=nil, @results=[#<ComposableContract::ResultAtPath result=#<Dry::Validation::Result{:arg1=>1, :arg2=>2} errors={:arg2=>["is not suitable"]}> path=[:guess1]>, #<ComposableContract::ResultAtPath result=#<Dry::Validation::Result{:arg1=>2, :arg2=>4} errors={}> path=[:guess2]>], @values=#<Dry::Validation::Values data={:guess1=>{:arg1=>1, :arg2=>2}, :guess2=>{:arg1=>2, :arg2=>4}}>>
result.success? # => false
result.values # => #<Dry::Validation::Values data={:guess1=>{:arg1=>1, :arg2=>2}, :guess2=>{:arg1=>2, :arg2=>4}}>
result.errors # => #<Dry::Validation::MessageSet messages=[#<Dry::Validation::Message text="is not suitable" path=[:guess1, :arg2] meta={}>] options={}>
result.errors.to_h # => {:guess1=>{:arg2=>["is not suitable"]}}
Right, I got that part, I was just curious if we really need both path :foo do ... end and contract SomeContract, path: :foo.
I guess block-based method would be helpful in drying up cases where you want to apply the same stuff to the same path
I like this plan with the exception of using your fork. If you’re interested in working on this feature I’m more than happy to just give you access to dry-validation repository.