Skip to content

Commit

Permalink
Adds support for composable contracts (experimental) [#593]
Browse files Browse the repository at this point in the history
  • Loading branch information
ianwhite committed Oct 9, 2019
1 parent e44430f commit 6c69da0
Show file tree
Hide file tree
Showing 5 changed files with 407 additions and 169 deletions.
220 changes: 76 additions & 144 deletions lib/dry/validation/extensions/composable.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require 'dry/validation/contract'
require 'dry/validation/extensions/composable/result_set'

module Dry
module Validation
Expand All @@ -9,7 +10,7 @@ module Validation
# @example
# Dry::Validation.load_extensions(:composable)
#
# class PlayerContract < ApplicationContract
# class PlayerContract < Dry::Validation::Contract
# schema do
# required(:name).filled(:string)
# required(:age).filled(:integer)
Expand All @@ -18,7 +19,7 @@ module Validation
# rule(:age).validate(gteq?: 18)
# end
#
# class CaptainContract < ApplicationContract
# class CaptainContract < Dry::Validation::Contract
# schema do
# required(:became_captain_on).filled(:date)
# end
Expand All @@ -28,169 +29,100 @@ module Validation
#
# @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])
Path = Dry::Schema::Path

module ContractExtensions
# Apply the contract to an input including any composition
#
# @param [Hash] input The input to validate
#
# @return [Result]
#
# @api public
def call(input)
result = super(input) if schema
return result if contracts.empty?

result = ResultSet.new([*result])

contracts.each do |(contract, path)|
result.add_result contract_result(input, contract, path), path
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?
result.freeze
end

def failure?
!success?
end
def inspect
return super if contracts.empty?

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?)
path_contracts = contracts.map do |(contract, path)|
path ? "#{path.to_a.join(DOT)}:#{contract}" : contract
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
super[0..-2] + " contracts=[#{path_contracts.join(', ')}]>"
end

def merge_all_values
@results.reduce({}) { |data, result| merge_hashes(data, result.to_h) }
end
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
def contract_result(input, contract, path)
contract = contract.new unless contract.respond_to?(:call)
input = input.dig(*path) if path
contract.call(input)
end
end

# Expose the given result's #to_h and #errors at the given path
# class interface for Contract with Composable extension
#
# @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?
module ContractClassInterface
def self.extended(contract)
contract.class_eval do
# we allow contracts with no schemas if they have contracts,
# see option :contracts default block

# @!attribute [r] schema
# @return [Dry::Schema::Params, Dry::Schema::JSON, Dry::Schema::Processor]
# @api private
option :schema, default: -> { self.class.__schema__ }

# @!attribute [r] contracts
# @return [Contract | [Contract, Path]]
# @api private
option :contracts, default: (lambda {
raise(SchemaMissingError, self.class) if !schema && self.class.contracts.empty?
self.class.contracts
})
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
# declare that the passed contract should be applied to the input,
# optionally at the specified path
#
# @example
# contract CustomerContract
#
# @example using a path
# contract AddressContract, path: :address
#
# @api public
def contract(contract, path: nil)
contracts << (path ? [contract, Path[path]] : contract)
end

# Return contracts defined in this class
#
# @return [Array<Contract|[Contract, Path]>]
#
# @api private
def contracts
@contracts ||= EMPTY_ARRAY
.dup
.concat(superclass.respond_to?(:contracts) ? superclass.contracts : EMPTY_ARRAY)
end
end
end

class Contract
# TODO: Implement composable contracts here!
Contract.prepend(ContractExtensions)
Contract.extend(ContractClassInterface)
end
end
end

0 comments on commit 6c69da0

Please sign in to comment.