diff --git a/CHANGELOG.md b/CHANGELOG.md index 8137c197..4c95fbbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ * Nested keys are properly handled when generating messages hash (issue #489) (flash-gordon + solnic) * Result objects support `locale` and `full` options now (solnic) * Ability to configure `top_namespace` for messages, which will be used for both schema and rule localization (solnic) +* Rule blocks receive a context object that you can use to share data between rules (solnic) ### Changed diff --git a/lib/dry/validation/contract.rb b/lib/dry/validation/contract.rb index 4db83e7d..5346ee7d 100644 --- a/lib/dry/validation/contract.rb +++ b/lib/dry/validation/contract.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'concurrent/map' + require 'dry/equalizer' require 'dry/initializer' @@ -86,10 +88,12 @@ class Contract # @api public def call(input) Result.new(schema.(input), locale: locale) do |result| + context = Concurrent::Map.new + rules.each do |rule| next if rule.keys.any? { |key| result.error?(key) } - rule.(self, result).failures.each do |failure| + rule.(self, result, context).failures.each do |failure| result.add_error(message_resolver[failure]) end end diff --git a/lib/dry/validation/evaluator.rb b/lib/dry/validation/evaluator.rb index 21626e28..23e91298 100644 --- a/lib/dry/validation/evaluator.rb +++ b/lib/dry/validation/evaluator.rb @@ -54,16 +54,21 @@ def failure(message, tokens = EMPTY_HASH) end end - # @!attribute [r] _context + # @!attribute [r] _contract # @return [Contract] # @api private - param :_context + param :_contract # @!attribute [r] keys # @return [Array] # @api private option :keys + # @!attribute [r] _context + # @return [Concurrent::Map] + # @api public + option :_context + # @!attribute [r] path # @return [Dry::Schema::Path] # @api private @@ -79,7 +84,7 @@ def failure(message, tokens = EMPTY_HASH) # @api private def initialize(*args, &block) super(*args) - instance_eval(&block) + instance_exec(_context, &block) end # Get failures object for the default or provided path @@ -120,18 +125,18 @@ def failures # @api private def respond_to_missing?(meth, include_private = false) - super || _context.respond_to?(meth, true) + super || _contract.respond_to?(meth, true) end private - # Forward to the underlying context + # Forward to the underlying contract # # @api private def method_missing(meth, *args, &block) # yes, we do want to delegate to private methods too - if _context.respond_to?(meth, true) - _context.__send__(meth, *args, &block) + if _contract.respond_to?(meth, true) + _contract.__send__(meth, *args, &block) else super end diff --git a/lib/dry/validation/result.rb b/lib/dry/validation/result.rb index 9684a71b..4259c529 100644 --- a/lib/dry/validation/result.rb +++ b/lib/dry/validation/result.rb @@ -94,25 +94,7 @@ def add_error(error) # # @api public def [](key) - if values.key?(key) - values[key] - elsif storage.key?(key) - storage[key] - end - end - - # Store value under specified key - # - # @param [Symbol] key - # @param [Object] value - # - # @return [Object] - # - # @api public - def []=(key, value) - raise ArgumentError, "Key +#{key}+ was already set" if key?(key) - - storage[key] = value + values[key] end # Check if a key was set @@ -123,7 +105,7 @@ def []=(key, value) # # @api public def key?(key) - values.key?(key) || storage.key?(key) + values.key?(key) end # Coerce to a hash @@ -159,11 +141,6 @@ def initialize_errors(options = self.options) def schema_errors(options) values.message_set(options).to_a end - - # @api private - def storage - @storage ||= EMPTY_HASH.dup - end end end end diff --git a/lib/dry/validation/rule.rb b/lib/dry/validation/rule.rb index 435e9a4b..6763b4ed 100644 --- a/lib/dry/validation/rule.rb +++ b/lib/dry/validation/rule.rb @@ -25,12 +25,13 @@ class Rule # Evaluate the rule within the provided context # - # @param [Contract] context - # @param [Object] values + # @param [Contract] contract + # @param [Result] result + # @param [Concurrent::Map] context # # @api private - def call(context, values) - Evaluator.new(context, values: values, keys: keys, &block) + def call(contract, result, context) + Evaluator.new(contract, values: result, keys: keys, _context: context, &block) end end end diff --git a/spec/integration/contract/evaluator/setting_values_spec.rb b/spec/integration/contract/evaluator/using_context_spec.rb similarity index 51% rename from spec/integration/contract/evaluator/setting_values_spec.rb rename to spec/integration/contract/evaluator/using_context_spec.rb index 1c0a4785..d5d97757 100644 --- a/spec/integration/contract/evaluator/setting_values_spec.rb +++ b/spec/integration/contract/evaluator/using_context_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe Dry::Validation::Evaluator, 'values writer' do +RSpec.describe Dry::Validation::Evaluator, 'using context' do context 'when key does not exist' do subject(:contract) do Dry::Validation::Contract.build do @@ -9,16 +9,16 @@ required(:user_id).filled(:integer) end - rule(:user_id) do + rule(:user_id) do |ctx| if values[:user_id].equal?(312) - values[:user] = 'jane' + ctx[:user] = 'jane' else key(:user).failure('must be jane') end end - rule(:email) do - key.failure('is invalid') if values[:user] == 'jane' && values[:email] != 'jane@doe.org' + rule(:email) do |ctx| + key.failure('is invalid') if ctx[:user] == 'jane' && values[:email] != 'jane@doe.org' end end end @@ -28,22 +28,4 @@ expect(contract.(user_id: 312, email: 'john@doe.org').errors.to_h).to eql(email: ['is invalid']) end end - - context 'when key already exists' do - subject(:contract) do - Dry::Validation::Contract.build do - schema do - required(:email).filled(:string) - end - - rule(:email) do - values[:email] = 'foo' - end - end - end - - it 'raises error' do - expect { contract.(email: 'jane@doe.org') }.to raise_error(ArgumentError, /email/) - end - end end diff --git a/spec/integration/evaluator_spec.rb b/spec/integration/evaluator_spec.rb index 4e0c23f0..e2eee3fc 100644 --- a/spec/integration/evaluator_spec.rb +++ b/spec/integration/evaluator_spec.rb @@ -4,15 +4,15 @@ RSpec.describe Dry::Validation::Evaluator do subject(:evaluator) do - Dry::Validation::Evaluator.new(context, options, &block) + Dry::Validation::Evaluator.new(contract, options, &block) end - let(:context) do - double(:context) + let(:contract) do + double(:contract) end let(:options) do - { keys: [:email], values: values } + { keys: [:email], values: values, _context: {} } end let(:values) do @@ -26,23 +26,23 @@ } end - it 'delegates to the context' do - expect(context).to receive(:works?).and_return(true) + it 'delegates to the contract' do + expect(contract).to receive(:works?).and_return(true) expect(evaluator.failures[0][:path].to_a).to eql([:email]) expect(evaluator.failures[0][:message]).to eql('it works') end - describe 'with custom methods defined on the context' do - let(:context) do - double(context: :my_context) + describe 'with custom methods defined on the contract' do + let(:contract) do + double(contract: :my_contract) end let(:block) do - proc { key.failure("message with #{context}") } + proc { key.failure("message with #{contract}") } end - it 'forwards to the context' do - expect(evaluator.failures[0][:message]).to eql('message with my_context') + it 'forwards to the contract' do + expect(evaluator.failures[0][:message]).to eql('message with my_contract') end end end diff --git a/spec/integration/result_spec.rb b/spec/integration/result_spec.rb index ec860adf..bfc2767b 100644 --- a/spec/integration/result_spec.rb +++ b/spec/integration/result_spec.rb @@ -15,18 +15,6 @@ end end - describe '#[]' do - let(:params) do - double(:params, message_set: [], to_h: {}, key?: false) - end - - it 'returns nil for missing values' do - Dry::Validation::Result.new(params) do |r| - expect(r[:missing]).to be nil - end - end - end - describe '#errors' do subject(:errors) { result.errors }