Skip to content

Commit

Permalink
Refactor composable internals [#593]
Browse files Browse the repository at this point in the history
  • Loading branch information
ianwhite committed Oct 9, 2019
1 parent 7ebb57c commit 7e0e34c
Show file tree
Hide file tree
Showing 10 changed files with 469 additions and 665 deletions.
64 changes: 64 additions & 0 deletions lib/dry/validation/composition.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# frozen_string_literal: true

require 'dry/validation/composition/step'

module Dry
module Validation
# A Composition is a list of steps
#
# @see Dry::Validation::Composable
#
# @api private
class Composition
include Dry::Equalizer(:steps, inspect: false)

# @return [Array]
#
# @api private
attr_reader :steps

# @param [Array] initial steps array
def initialize(steps = EMPTY_ARRAY)
@steps = steps.dup
end

def inspect
steps_str = steps.map do |s|
s.prefix ? "#{s.prefix.to_a.join(DOT)} => #{s.contract}" : s.contract
end.join(', ')

"#<#{self.class.name} steps=[#{steps_str}]>"
end

# @return [Bool]
#
# @api private
def empty?
steps.empty?
end

# apply our steps to the input recording in result
#
# @param [Composition::Result]
# @param [Hash]
#
# @return [Composition::Result]
#
# @api private
def call(input, result = Composition::Result.new)
steps.reduce(result) { |final_result, step| step.call(input, final_result) }
end

# add a new step to the composition
#
# @param [Contract]
# @param [Schema::Path] optional
#
# @api private
def add_step(contract, prefix)
steps << Step.new(contract, prefix)
self
end
end
end
end
160 changes: 160 additions & 0 deletions lib/dry/validation/composition/result.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# frozen_string_literal: true

require 'dry/validation/result_interface'

module Dry
module Validation
class Composition
# A Composition::Result is built from many results with optional
# input prefixes.
#
# @see ResultInterface
#
# @api public
class Result
include ResultInterface

include Dry::Equalizer(:errors, :values, inspect: false)

# Build a new result
#
# @param [Dry::Schema::Result] schema_result
#
# @api private
def self.new(options = EMPTY_HASH)
result = super
yield(result) if block_given?
result.freeze
end

# Result options
#
# @return [Hash]
#
# @api private
attr_reader :options

# Result values
#
# @return [Values]
#
# @api public
attr_reader :values

# prefixed results
#
# @return [Array[Schema::Path, Result]]
#
# @api private
attr_reader :prefixed_results

# Initialize a new result
#
# @api private
def initialize(options)
@options = options
@values = Values.new({})
@errors = MessageSet.new([], options)

# prefixed results is only necessary so that we can rebuild error
# messages when passed options to #errors.
#
# TODO: can we see a way of having a MessageSet be able to rebuild
# itself given new options?
@prefixed_results = []
end

# Freeze values and errors
#
# @api private
def freeze
values.freeze
errors.freeze
@prefixed_results.freeze
super
end

# Add a result, merging its values, and adding its errors, optionally prefixed
#
# @api private
def add_result(result, prefix = nil)
add_values(result.to_h, prefix)
prefixed_results << [prefix, result]
@errors = build_errors(options)
self
end

# Get error set
#
# @!macro errors-options
# @param [Hash] new_options
# @option new_options [Symbol] :locale Set locale for messages
# @option new_options [Boolean] :hints Enable/disable hints
# @option new_options [Boolean] :full Get messages that include key names
#
# @return [MessageSet]
#
# @api public
def errors(new_options = EMPTY_HASH)
new_options.empty? ? @errors : build_errors(new_options)
end

private

# merge the supplied hash into our values
#
# @api private
def add_values(hash, prefix)
hash = prefixed_hash(hash, prefix) if prefix
@values = Values.new(merge_hashes(@values.to_h, hash))
end

# build the errors from scratch using the supplied options
#
# @api private
def build_errors(options)
empty_set = MessageSet.new([], options)
prefixed_results.each_with_object(empty_set) do |(prefix, result), errors|
result.errors(options).each do |error|
path = prefix ? Schema::Path[prefix].to_a + error.path : error.path
errors.add Message[error.text, path, error.meta]
end
end
end

# prefix the passed hash input with the given path prefix
#
# @example
# prefixed_hash({foo: 1}, 'bar.baz') # => { bar: { baz: { foo: 1 } } }
#
# @api private
def prefixed_hash(input, prefix)
prefix = Schema::Path[prefix].to_a
output = prefix.reverse.reduce({}) { |m, key| { key => m } }
output.dig(*prefix).merge!(input)
output
end

# merge hashes, merging values only in the case of a conflict where both are hash
#
# @example
#
# l = { a: { b: 1 }, d: [1] }
# r = { a: { c: 2 }, d: [2] }
#
# merge_hashes(l, r) # => { a: { b: 1, c: 2 }, d: [2] }
#
# @api private
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
end
end
end
38 changes: 27 additions & 11 deletions lib/dry/validation/contract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,17 +91,7 @@ class Contract
#
# @api public
def call(input)
Result.new(schema.(input), Concurrent::Map.new) do |result|
rules.each do |rule|
next if rule.keys.any? { |key| error?(result, key) }

rule_result = rule.(self, result)

rule_result.failures.each do |failure|
result.add_error(message_resolver[failure])
end
end
end
contract_result(input)
end

# Return a nice string representation
Expand Down Expand Up @@ -155,6 +145,32 @@ def macro(name, *args)
def messages
self.class.messages
end

# Return the result of schema on input with the rules applied
#
# @return [Result]
#
# @api private
def contract_result(input)
Result.new(schema.(input), Concurrent::Map.new) do |result|
apply_rules(result, rules)
end
end

# Iterate rules and add errors to the result
#
# @api private
def apply_rules(result, rules)
rules.each do |rule|
next if rule.keys.any? { |key| error?(result, key) }

rule_result = rule.(self, result)

rule_result.failures.each do |failure|
result.add_error(message_resolver[failure])
end
end
end
end
end
end
Loading

0 comments on commit 7e0e34c

Please sign in to comment.