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