Thoughts on dry-validation wrt: composing validations

I’ve been trying to convert some active record validations to dry-validations 1.x recently and I’ve hit a couple of problems.

  1. 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).
  2. 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.

1 Like

I’ve made a couple of changes, and put the code for this spike at: https://gist.github.com/ianwhite/a32fcd439020ea07ed1fe3243152274f

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

More updates at this gist

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 :100: 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)
  • [ ] Document the above changes.

Also, FYI, the code currently allows for checks to be lambdas, so that simple cases can just check the existence of keys. Eg.

class ExampleContract < ApplicationContract
  contract CustomerContract
  contract AddressContract, path: :address, check: -> { values[:address] }
end

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"]}}

This example and a pasteable self contained piece of code is at https://gist.github.com/ianwhite/918c7ba3487e05b2272a42c154e6b6fb

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 :smile:

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.

Hi @solnic, I’m happy to work on this feature. Once I have access, I’ll start a ticket and feature branch there. Thanks! :smile:

2 Likes