How do I define helper methods to be used across rules?

I have rules with repeated logic in them that I would like to extract to prevent duplication. Here, I’m using values[:age] < 21 as an example of the repeated logic:

# frozen_string_literal: true

require 'dry-validation'

class MyContract < Dry::Validation::Contract
  params do
    required(:age).value(:integer)
    optional(:rent_car).value(:bool)
    optional(:rent_boat).value(:bool)
  end

  rule(:rent_car, :age) do
    key.failure("Not old enough to rent a car") if values[:age] < 21
  end

  rule(:rent_boat, :age) do
    key.failure("Not old enough to rent a boat") if values[:age] < 21
  end
end

validation = MyContract.new.call(age: 10, rent_car: true, rent_boat: true)
p validation.errors[:rent_car]
p validation.errors[:rent_boat]

My naive attempt to define a method fails:

class MyContract < Dry::Validation::Contract
  params do
    required(:age).value(:integer)
    optional(:rent_car).value(:bool)
    optional(:rent_boat).value(:bool)
  end

  rule(:rent_car, :age) do
    key.failure("Not old enough to rent a car") unless old_enough?
  end

  rule(:rent_boat, :age) do
    key.failure("Not old enough to rent a boat") unless old_enough?
  end

  def old_enough?
    values[:age] >= 21
  end
end
Traceback (most recent call last):
	12: from repro.rb:25:in `<main>'
	11: from /.../gems/dry-validation-1.2.1/lib/dry/validation/contract.rb:94:in `call'
	10: from /.../gems/dry-validation-1.2.1/lib/dry/validation/result.rb:25:in `new'
	 9: from /.../gems/dry-validation-1.2.1/lib/dry/validation/contract.rb:95:in `block in call'
	 8: from /.../gems/dry-validation-1.2.1/lib/dry/validation/contract.rb:95:in `each'
	 7: from /.../gems/dry-validation-1.2.1/lib/dry/validation/contract.rb:98:in `block (2 levels) in call'
	 6: from /.../gems/dry-validation-1.2.1/lib/dry/validation/rule.rb:38:in `call'
	 5: from /.../gems/dry-validation-1.2.1/lib/dry/validation/rule.rb:38:in `new'
	 4: from /.../gems/dry-validation-1.2.1/lib/dry/validation/evaluator.rb:73:in `initialize'
	 3: from /.../gems/dry-validation-1.2.1/lib/dry/validation/evaluator.rb:73:in `instance_exec'
	 2: from repro.rb:13:in `block in <class:MyContract>'
	 1: from /.../gems/dry-validation-1.2.1/lib/dry/validation/evaluator.rb:190:in `method_missing'
repro.rb:21:in `old_enough?': undefined local variable or method `values' for #<MyContract:0x00007fa3799928d8> (NameError)

What is the appropriate pattern to avoid repeating logic like this?

What do you think about:

class MyContract < Dry::Validation::Contract
  params do
    required(:age).value(:integer)
    optional(:vehicle).value(:bool)
  end

  rule(:vehicle, :age) do
    key.failure("Not old enough to rent a vehicle") if values[:age] < 21
  end
end

You could do this:

require 'dry-validation'

class MyContract < Dry::Validation::Contract
  register_macro(:old_enough?) do
    type = keys.last

    if values[type] && value < 21
      name = { rent_car: "a car", rent_boat: "a boat" }[type]
      key.failure("Not old enough to rent #{name}")
    end
  end
  
  params do
    required(:age).value(:integer)
    optional(:rent_car).value(:bool)
    optional(:rent_boat).value(:bool)
  end

  rule(:age, :rent_car).validate(:old_enough?)
  rule(:age, :rent_boat).validate(:old_enough?)
end

my_contract = MyContract.new

puts my_contract.(age: 12, rent_car: true, rent_boat: true).errors.inspect
#<Dry::Validation::MessageSet messages=[#<Dry::Validation::Message text="Not old enough to rent a car" path=[:age] meta={}>, #<Dry::Validation::Message text="Not old enough to rent a boat" path=[:age] meta={}>] options={}>

puts my_contract.(age: 12, rent_car: true, rent_boat: false).errors.inspect
#<Dry::Validation::MessageSet messages=[#<Dry::Validation::Message text="Not old enough to rent a car" path=[:age] meta={}>] options={}>

puts my_contract.(age: 12, rent_car: false, rent_boat: true).errors.inspect
#<Dry::Validation::MessageSet messages=[#<Dry::Validation::Message text="Not old enough to rent a boat" path=[:age] meta={}>] options={}>

puts my_contract.(age: 21, rent_car: true, rent_boat: true).errors.inspect
#<Dry::Validation::MessageSet messages=[] options={}>

puts my_contract.(age: 12, rent_car: false, rent_boat: false).errors.inspect
#<Dry::Validation::MessageSet messages=[] options={}>

To make it nicer, I would use localized messages and use failure identifier rather than building up that string message.

For anyone else reading this post, it is (now) possible to include methods and modules inside the contract class that can be accessed in the rule. I have been trying to work out how to have the rules short circuit (ie. not continue for a parameter once the first rule has failed) and this is the only vaguely elegant way I’ve worked out how to do it so far.

require "bundler/inline"

gemfile do
  source "https://rubygems.org"
  gem "dry-validation", "1.10"
  gem "pry-byebug"
end

module ValidationHelpers
  extend self

  def validate_phone_number(value, key)
    if !value.start_with?("04")
      key.failure("Phone number must start with 04")
    end
  end

  def validate_not_blank(value, key)
    if value.strip.size == 0
      key.failure("must contain some chars")
    end
  end
end

Dry::Validation.register_macro(:not_blank) do
  ValidationHelpers.validate_not_blank(value, key)
end

class PersonContract < Dry::Validation::Contract
  include ValidationHelpers

  json do
    required(:phone_number).filled(:string)
  end

  rule(:phone_number).validate(:not_blank)

  rule(:phone_number) do
    validate_phone_number(value, key) unless rule_error?(:phone_number)
  end
end

puts PersonContract.new.call({ phone_number: " ", foo: nil}).errors.to_hash
# {:phone_number=>["must contain some chars"]}

puts PersonContract.new.call({ phone_number: "1", foo: nil}).errors.to_hash
# {:phone_number=>["Phone number must start with 04"]}