Last active
September 27, 2019 11:39
-
-
Save ianwhite/918c7ba3487e05b2272a42c154e6b6fb to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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