Skip to content

Commit

Permalink
Adds #path class method on :composable contracts [#593]
Browse files Browse the repository at this point in the history
  • Loading branch information
ianwhite committed Oct 7, 2019
1 parent 9fb0ab8 commit 1ca646b
Show file tree
Hide file tree
Showing 2 changed files with 97 additions and 61 deletions.
133 changes: 82 additions & 51 deletions lib/dry/validation/extensions/composable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,46 +31,6 @@ module Validation
module Composable
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

result.freeze
end

def inspect
return super if contracts.empty?

path_contracts = contracts.map do |(contract, path)|
path ? "#{path.to_a.join(DOT)}:#{contract}" : contract
end

super[0..-2] + " contracts=[#{path_contracts.join(', ')}]>"
end

private

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

# class interface for Contract with Composable extension
#
# @api private
Expand All @@ -88,10 +48,7 @@ def self.extended(contract)
# @!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
})
option :contracts_at_paths, default: -> { default_contracts_at_paths }
end
end

Expand All @@ -106,22 +63,96 @@ def self.extended(contract)
#
# @api public
def contract(contract, path: nil)
contracts << (path ? [contract, Path[path]] : contract)
@current_path ||= nil
path = Path[path] if path
path = Path[[*@current_path, *path]] if @current_path || path

contracts_at_paths << [contract, path]
end

# scope enclosed contracts to the specified path
#
# @example
# path :address do
# contract AddressContract
#
# path :location do
# contract GeoLocation
# end
# end
#
# @api public
def path(path)
path = Path[path]
prev_path = @current_path
@current_path = Path[[*@current_path, *path]]
yield
ensure
@current_path = prev_path
end

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

module ContractInterface
# 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_at_paths.empty?

result = ResultSet.new([*result])

contracts_at_paths.each do |(contract, path)|
result.add_result contract_result(input, contract, path), path
end

result.freeze
end

def inspect
return super if contracts_at_paths.empty?

contracts_str = contracts_at_paths.map do |(contract, path)|
path ? "#{path.to_a.join(DOT)}:#{contract}" : contract
end

super[0..-2] + " contracts=[#{contracts_str.join(', ')}]>"
end

private

def contract_result(input, contract, path)
contract = contract.new unless contract.respond_to?(:call)
input = input.dig(*path) if path
contract.call(input)
end

def default_contracts_at_paths
contracts_at_paths = self.class.contracts_at_paths
raise SchemaMissingError, self.class if !schema && contracts_at_paths.empty?

contracts_at_paths
end
end

Contract.prepend(ContractExtensions)
Contract.prepend(ContractInterface)
Contract.extend(ContractClassInterface)
end
end
Expand Down
25 changes: 15 additions & 10 deletions spec/extensions/composable/composable_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ class Test::ComposedContract < Dry::Validation::Contract
describe '#call(input)' do
subject(:result) { contract.call(input) }

let(:input) { { email: 'moss@reynholm.com',
let(:input) { { email: 'moss#reynholm.com',
name: 'Moss',
phone: '01189998819991197253' } }

Expand All @@ -91,13 +91,14 @@ class Test::ComposedContract < Dry::Validation::Contract
end

it 'has appropriate values' do
expect(result.to_h).to eq(email: 'moss@reynholm.com',
expect(result.to_h).to eq(email: 'moss#reynholm.com',
name: 'Moss',
phone: '01189998819991197253')
end

it 'has appropriate errors' do
expect(result.errors.to_h).to eq(phone: ['is emergency services'])
expect(result.errors.to_h).to eq(email: ['is in invalid format'],
phone: ['is emergency services'])
end
end
end
Expand All @@ -113,8 +114,14 @@ class Test::PersonContract < Dry::Validation::Contract
let(:contract_class) do
class Test::ComposedContract < Dry::Validation::Contract
contract Test::PersonContract
contract Test::PersonContract, path: 'emergency'
contract Test::PersonContract, path: 'emergency.backup'

path :emergency do
contract Test::PersonContract

path :backup do
contract Test::PersonContract
end
end
end

Test::ComposedContract
Expand All @@ -131,19 +138,17 @@ class Test::ComposedContract < Dry::Validation::Contract

let(:input) { { email: 'foo',
name: '',
other: 'bad',
shmemergency: { more_noise: 'XXXXXX' },
shmemergency: { noise: 'XXXXXX' },
emergency: { name: 'smudger',
whoops: 'why is this here?',
backup: { email: 'ok@email.com',
name: '',
noise: 'ZZZZ' } } } }
name: '' } } } }

it 'returns a result' do
expect(subject).to be_failure
end

it 'has the schemafied values at the specified paths' do
it 'has appropriate values at the specified paths' do
expect(result.to_h).to eq(email: 'foo',
name: '',
emergency: { name: 'smudger',
Expand Down

0 comments on commit 1ca646b

Please sign in to comment.