Skip to content

Commit

Permalink
Adds ResultSet and tests [#593]
Browse files Browse the repository at this point in the history
  • Loading branch information
ianwhite committed Oct 9, 2019
1 parent cdbf0e6 commit e44430f
Show file tree
Hide file tree
Showing 3 changed files with 294 additions and 0 deletions.
162 changes: 162 additions & 0 deletions lib/dry/validation/extensions/composable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,168 @@ module Validation
# end
#
# @api public
module Composable
# A ResultSet is composed of results, optionally mounted at paths.
# Add results with #add_result(result, path).
#
# @api private
class ResultSet
def initialize(results = [])
@success = nil
@message_set = nil
@results = []

results.each { |r| add_result(r) }
end

# add a result to the set, optionally at the path
def add_result(result, path = nil)
result = ResultAtPath.new(result, path) if path

@values = nil
@success = nil
@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 [](key)
values[key]
end

def key?(key)
values.key?(key)
end

def to_h
values.to_h
end

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| merge_hashes(data, result.to_h) }
end

def merge_hashes(left, right)
left.merge(right) do |key, left_val, right_val|
if left_val.is_a?(Hash) && right_val.is_a?(Hash)
merge_hashes(left_val, right_val)
else
right_val
end
end
end
end

# Expose the given result's #to_h and #errors at the given path
#
# @api private
class ResultAtPath
attr_reader :result, :path

def initialize(result, path)
@message_set = nil
@result = result
@path = Dry::Schema::Path[path].to_a
end

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

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 Contract
# TODO: Implement composable contracts here!
end
Expand Down
15 changes: 15 additions & 0 deletions lib/dry/validation/extensions/monads.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,20 @@ def to_monad
success? ? Success(self) : Failure(self)
end
end

module Extensions
module Composable
# Monad extension for composable contract results
#
# @api private
class ResultSet
include Dry::Monads::Result::Mixin

def to_monad
success? ? Success(self) : Failure(self)
end
end
end
end
end
end
117 changes: 117 additions & 0 deletions spec/extensions/composable/result_set_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# frozen_string_literal: true

require 'dry/validation/extensions/composable'

RSpec.describe Dry::Validation::Composable::ResultSet do
subject(:result_set) { Dry::Validation::Composable::ResultSet.new(results) }

let(:results) { [] }

let(:email_schema) do
Dry::Schema.define { required(:email).value(:string, format?: /@/) }
end

let(:name_schema) do
Dry::Schema.define { required(:name).filled(:string) }
end

context 'with no results' do
it { is_expected.to be_success }

it '#to_h returns an empty hash' do
expect(result_set.to_h).to eq({})
end
end

context 'with 2 successful results' do
let(:results) do
[email_schema[email: 'foo@bar'], name_schema[name: 'Fred']]
end

it { is_expected.to be_success }

it '#to_h has the values from both results' do
expect(result_set.to_h).to eq(email: 'foo@bar', name: 'Fred')
end
end

context '1 successful result, 1 failure result' do
let(:results) do
[email_schema[email: 'nope'], name_schema[name: 'Fred']]
end

it { is_expected.to be_failure }

it '#to_h has the values from both results' do
expect(result_set.to_h).to eq(email: 'nope', name: 'Fred')
end

it 'has the errors from the failure' do
expect(result_set.errors(full: true).map(&:to_s))
.to eq ['email is in invalid format']

expect(result_set.errors.to_h).to eq(email: ['is in invalid format'])
end
end

context '2 failure results' do
let(:results) do
[email_schema[email: 'nope'], name_schema[name: '']]
end

it { is_expected.to be_failure }

it '#to_h has the values from both results' do
expect(result_set.to_h).to eq(email: 'nope', name: '')
end

it 'has the errors from the failures' do
expect(result_set.errors(full: true).map(&:to_s))
.to eq ['email is in invalid format', 'name must be filled']

expect(result_set.errors.to_h).to eq(email: ['is in invalid format'],
name: ['must be filled'])
end
end

context 'multiple failure results for the same key' do
let(:smudge_schema) do
Dry::Schema.define { required(:name).value(:string, eql?: 'smudge') }
end

let(:results) do
[name_schema[name: ''], smudge_schema[name: '']]
end

it 'concatenates the errors at the key' do
expect(result_set.errors.to_h).to eq(name: ['must be filled', 'must be equal to smudge'])
end
end

describe '#add_result' do
context 'results at different paths' do
before do
result_set.add_result(email_schema[email: 'paino'], :keeper)
result_set.add_result(email_schema[email: 'smudger'], :captain)
result_set.add_result(email_schema[email: 'gary'], 'leg.fine')
result_set.add_result(email_schema[email: 'cummins'], [:leg, :square, :deep])
end

it '#to_h has values at the specified paths' do
expect(result_set.to_h)
.to eq(captain: { email: "smudger"},
keeper: { email: "paino"},
leg: { fine: { email: 'gary' },
square: { deep: { email: "cummins"} } })
end

it '#errors has messages at the specified paths' do
expect(result_set.errors.to_h)
.to eq(captain: { email: ['is in invalid format']},
keeper: { email: ['is in invalid format']},
leg: { fine: { email: ['is in invalid format'] },
square: { deep: { email: ['is in invalid format'] } } })
end
end
end
end

0 comments on commit e44430f

Please sign in to comment.