Skip to content

Instantly share code, notes, and snippets.

@ianwhite
Last active September 27, 2019 11:39
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ianwhite/918c7ba3487e05b2272a42c154e6b6fb to your computer and use it in GitHub Desktop.
Save ianwhite/918c7ba3487e05b2272a42c154e6b6fb to your computer and use it in GitHub Desktop.
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 file can be pasted into irb if dry-validation is available
# the example above is at the bottom of the file
require 'dry/validation'
module ComposableContract
# this is the interface that we care about, we provide the rest of
# result's interface where required
ResultType = Types.Interface(:to_h, :errors)
# this is our representation of a path, we use Schema::Path to
# convert where necessary
PathType = Types::Array.of(Types::Symbol)
CheckType = Types.Interface(:call)
ChecksType = Types::Array(CheckType)
ChecksDictType = Types::Hash.map(Types::Symbol, CheckType)
def self.compose(&block)
builder(&block).to_contract
end
def self.builder(&block)
DSL.new(&block)
end
def self.included(klass)
klass.extend ClassInterface
end
# if included in a contract, start the composition with the contract
# result, otherwise start from scratch
def call(input)
result = super if method(:call).super_method
result = composition.call(input, result) if composition
result
end
private
def composition
self.class.instance_variable_get(:@composition)
end
module ClassInterface
def compose(&block)
@composition = ComposableContract.compose(&block)
end
end
# a step contains a contract, optional checks, and optional path
class Step < Dry::Struct
attribute :contract, Types.Interface(:call) | Types.Instance(Class)
attribute :checks, ChecksType
attribute :path, PathType.optional
# collaborates to add results of this contract to the result set
def call(input, result_set)
return result_set unless checks_pass?(result_set)
the_input = path ? input.dig(*path) : input
the_contract = contract.respond_to?(:call) ? contract : contract.new
the_result = the_contract.call(the_input)
result_set.add_result(the_result, path)
end
private
def checks_pass?(result_set)
check_evaluator = CheckEvaluator.new(result_set.values)
checks.all? { |c| check_evaluator.instance_exec(&c) }
end
CheckEvaluator = Struct.new(:values)
end
# a composition is an ordered list of steps, quacks like a Contract
# but allows for an initial result to be passed to #call
class Composition < Dry::Struct
attribute :steps, Types.Array(Step)
# note optional extra argument result
def call(input, result = nil)
starting_result_set = ResultSet.new(result ? [result] : [])
steps.each_with_object(starting_result_set) do |step, result_set|
step.call(input, result_set)
end.freeze
end
end
# DSL exposes #steps (an ordered list of steps), suitable for a Composition
class DSL
# can be passed a 1-arity or zero arity block
def initialize(&block)
@steps = [] # 3-tuples of [contract, path, checks]
@checks_dict = {}
block.arity == 1 ? block.call(self) : instance_exec(&block) if block
end
# before returning steps, resolve any named checks
def steps
@steps.map do |(contract, path, checks)|
checks = checks.map { |c| c.is_a?(Symbol) ? @checks_dict.fetch(c) : c }
Step.new(contract: contract, checks: checks, path: path)
end
end
def to_contract
Composition.new(steps: steps)
end
def contract(contract, check: nil, path: nil)
path = Dry::Schema::Path[path].to_a if path
path = [*@current_path, *path] if path || @current_path
checks = [*@current_checks, *check]
@steps << [contract, path, checks]
end
def check(*checks)
prev_checks = @current_checks
@current_checks = [*@current_checks, *checks]
yield
ensure
@current_checks = prev_checks
end
def path(path)
prev_path = @current_path
@current_path = [*@current_path, *Dry::Schema::Path[path].to_a]
yield
ensure
@current_path = prev_path
end
def register_check(name, &block)
@checks_dict = ChecksDictType[@checks_dict.merge(name => block)]
end
end
# quacks like a Dry::Validation::Result, except #add_error.
# Can be composed of results (#add_result) optionally mounted at paths.
# its values and errors are merged from all of its results
class ResultSet
extend Forwardable
def initialize(results = [])
@success = nil
@message_set = nil
@results = []
results.each { |r| add_result(r) }
end
def add_result(result, path = nil)
@values = nil
@success = nil
result = ResultType[result]
result = ResultAtPath[result: result, path: path] if path
@results << result
self
end
def freeze
@results.map(&:freeze)
success?
message_set.freeze
values.freeze
super
end
def values
@values ||= Dry::Validation::Values.new(merge_all_values)
end
def_delegators :values, :[], :key?, :to_h
def errors(new_options = {})
new_options.empty? ? message_set : collate_all_messages(new_options)
end
def error?(key)
message_set.any? do |msg|
Dry::Schema::Path[msg.path].include?(Dry::Schema::Path[key])
end
end
def base_error?(key)
message_set.any? do |msg|
key_path = Dry::Schema::Path[key]
err_path = Dry::Schema::Path[msg.path]
return false unless key_path.same_root?(err_path)
key_path == err_path
end
end
def success?
return @success unless @success.nil?
@success = message_set.empty?
end
def failure?
!success?
end
private
def message_set
# memoize result errors only if underlying results are all frozen
@message_set || collate_all_messages.tap do |message_set|
@message_set = message_set unless @results.all?(&:frozen?)
end
end
def collate_all_messages(options = {})
empty_message_set = Dry::Validation::MessageSet.new([], options)
@results.each_with_object(empty_message_set) do |result, errors|
result.errors(options).each { |m| errors.add(m) }
end
end
def merge_all_values
@results.reduce({}) { |data, result| data.merge(result.to_h) }
end
end
# api private
class ResultAtPath < Dry::Struct
attribute :result, ResultType
attribute :path, PathType
def to_h
@to_h ||= hash_at_path
end
def errors(new_options = {})
new_options.empty? ? message_set : errors_at_path(new_options)
end
def freeze
result.freeze
message_set.freeze
to_h.freeze
super
end
def add_error(*args)
result.add_error(*args)
end
private
def message_set
# memoize result errors only if underlying result is frozen
@message_set || errors_at_path.tap do |message_set|
@message_set = message_set unless result.frozen?
end
end
def hash_at_path
data = path.reverse.reduce({}) { |m, key| { key => m } }
data.dig(*path).merge!(result.to_h)
data
end
def errors_at_path(options = {})
empty_message_set = Dry::Validation::MessageSet.new([], options)
result.errors(options).each_with_object(empty_message_set) do |m, errors|
errors.add Dry::Validation::Message[m.text, path + m.path, m.meta]
end
end
end
end
###########
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"]}}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment