From 8fd0a262185fc75a06391ec2e923a4285a9758d4 Mon Sep 17 00:00:00 2001 From: Matheus Sales Date: Fri, 13 Sep 2024 11:06:32 -0300 Subject: [PATCH] feat: Add support for validating multiple attributes at once This commit adds support for validating multiple attributes at once with `validate_presence_of`. This is useful when you want to ensure that multiple attributes are required. ```ruby class Example include ActiveModel::Model attr_accessor :attr1, :attr2 validates_presence_of :attr1, :attr2 end RSpec.describe Example do it do expect(subject).to validate_presence_of(:attr1, :attr2) end end ``` We also add support for using qualifiers with multiple attributes. There's two caveats: if you use a qualifier, it will apply to all attributes and only the first failure will be reported. ```ruby class Example include ActiveModel::Model attr_accessor :attr1, :attr2 validates_presence_of :attr1, allow_nil: true validates_presence_of :attr2, allow_nil: true end RSpec.describe Example do it do expect(subject).to validate_presence_of(:attr1, :attr2) end end ``` --- lib/shoulda/matchers/active_model.rb | 1 + .../active_model/allow_value_matcher.rb | 2 +- lib/shoulda/matchers/active_model/helpers.rb | 4 +- .../active_model/matcher_collection.rb | 59 +++++++++++++++++ .../validate_presence_of_matcher.rb | 6 +- .../matchers/active_model/validator.rb | 4 ++ .../validate_presence_of_matcher_spec.rb | 63 +++++++++++++++++++ 7 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 lib/shoulda/matchers/active_model/matcher_collection.rb diff --git a/lib/shoulda/matchers/active_model.rb b/lib/shoulda/matchers/active_model.rb index 5a20b0019..6ec32c89f 100644 --- a/lib/shoulda/matchers/active_model.rb +++ b/lib/shoulda/matchers/active_model.rb @@ -1,5 +1,6 @@ require 'shoulda/matchers/active_model/helpers' require 'shoulda/matchers/active_model/qualifiers' +require 'shoulda/matchers/active_model/matcher_collection' require 'shoulda/matchers/active_model/validation_matcher' require 'shoulda/matchers/active_model/validation_matcher/build_description' require 'shoulda/matchers/active_model/validator' diff --git a/lib/shoulda/matchers/active_model/allow_value_matcher.rb b/lib/shoulda/matchers/active_model/allow_value_matcher.rb index 29d2aa3a0..2f9d9e236 100644 --- a/lib/shoulda/matchers/active_model/allow_value_matcher.rb +++ b/lib/shoulda/matchers/active_model/allow_value_matcher.rb @@ -458,7 +458,7 @@ def failure_message message << '.' else message << " producing these validation errors:\n\n" - message << validator.all_formatted_validation_error_messages + message << validator.formatted_validation_error_messages end end diff --git a/lib/shoulda/matchers/active_model/helpers.rb b/lib/shoulda/matchers/active_model/helpers.rb index bf23a71f2..31aab6735 100644 --- a/lib/shoulda/matchers/active_model/helpers.rb +++ b/lib/shoulda/matchers/active_model/helpers.rb @@ -7,8 +7,10 @@ def pretty_error_messages(object) format_validation_errors(object.errors) end - def format_validation_errors(errors) + def format_validation_errors(errors, attr = nil) list_items = errors.to_hash.keys.map do |attribute| + next if attr && attr.to_sym != attribute.to_sym + messages = errors[attribute] "* #{attribute}: #{messages}" end diff --git a/lib/shoulda/matchers/active_model/matcher_collection.rb b/lib/shoulda/matchers/active_model/matcher_collection.rb new file mode 100644 index 000000000..e417aee2f --- /dev/null +++ b/lib/shoulda/matchers/active_model/matcher_collection.rb @@ -0,0 +1,59 @@ +module Shoulda + module Matchers + module ActiveModel + # @private + class MatcherCollection + def initialize(matchers) + @matchers = matchers + end + + def matches?(subject) + @failed_matchers = failed_matchers_for(subject, :matches?) + @failed_matchers.empty? + end + + def does_not_match?(subject) + @failed_matchers = failed_matchers_for(subject, :does_not_match?) + @failed_matchers.empty? + end + + def failure_message + first_failure_message(:failure_message) + end + + def failure_message_when_negated + first_failure_message(:failure_message_when_negated) + end + + def method_missing(method, *args, &block) + if all_matchers_respond_to?(method) + matchers.each { |matcher| matcher.send(method, *args, &block) } + self + else + super + end + end + + def respond_to_missing?(method, include_private = false) + all_matchers_respond_to?(method) || super + end + + private + + attr_reader :matchers + + def failed_matchers_for(subject, method) + matchers.reject { |matcher| matcher.send(method, subject) } + end + + def first_failure_message(method) + @failed_matchers.first&.send(method) + end + + def all_matchers_respond_to?(method) + matchers.all? { |matcher| matcher.respond_to?(method) } + end + end + end + end +end diff --git a/lib/shoulda/matchers/active_model/validate_presence_of_matcher.rb b/lib/shoulda/matchers/active_model/validate_presence_of_matcher.rb index 9b047e7a8..77f5ad1c2 100644 --- a/lib/shoulda/matchers/active_model/validate_presence_of_matcher.rb +++ b/lib/shoulda/matchers/active_model/validate_presence_of_matcher.rb @@ -147,8 +147,10 @@ module ActiveModel # # @return [ValidatePresenceOfMatcher] # - def validate_presence_of(attr) - ValidatePresenceOfMatcher.new(attr) + + def validate_presence_of(*attrs) + matchers = attrs.map { |attr| ValidatePresenceOfMatcher.new(attr) } + MatcherCollection.new(matchers) end # @private diff --git a/lib/shoulda/matchers/active_model/validator.rb b/lib/shoulda/matchers/active_model/validator.rb index 7e66a8d8a..e21fb9144 100644 --- a/lib/shoulda/matchers/active_model/validator.rb +++ b/lib/shoulda/matchers/active_model/validator.rb @@ -45,6 +45,10 @@ def validation_exception_message validation_result[:validation_exception_message] end + def formatted_validation_error_messages + format_validation_errors(all_validation_errors, attribute) + end + protected attr_reader :attribute, :context, :record diff --git a/spec/unit/shoulda/matchers/active_model/validate_presence_of_matcher_spec.rb b/spec/unit/shoulda/matchers/active_model/validate_presence_of_matcher_spec.rb index a416b60d9..3ea9032b9 100644 --- a/spec/unit/shoulda/matchers/active_model/validate_presence_of_matcher_spec.rb +++ b/spec/unit/shoulda/matchers/active_model/validate_presence_of_matcher_spec.rb @@ -4,6 +4,69 @@ include UnitTests::ApplicationConfigurationHelpers context 'a model with a presence validation' do + context 'passing multiple attributes' do + it 'accepts' do + model = define_model 'Example', attr1: :string, attr2: :string do + validates_presence_of(:attr1) + validates_presence_of(:attr2) + end + + expect(model.new).to validate_presence_of(:attr1, :attr2) + end + + it 'fails when used in the negative' do + model = define_model 'Example', attr1: :string, attr2: :string do + validates_presence_of(:attr1) + end + + assertion = lambda do + expect(model.new).not_to validate_presence_of(:attr1, :attr2) + end + + message = <<-MESSAGE +Expected Example not to validate that :attr1 cannot be empty/falsy, but +this could not be proved. + After setting :attr1 to ‹nil›, the matcher expected the Example to be + valid, but it was invalid instead, producing these validation errors: + + * attr1: ["can't be blank"] + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + + it 'accepts when using qualifiers' do + model = define_model 'Example', attr1: :string, attr2: :string do + validates_presence_of(:attr1, allow_nil: true) + validates_presence_of(:attr2, allow_nil: true) + end + + expect(model.new).to validate_presence_of(:attr1, :attr2).allow_nil + end + + it 'rejects when one attribute does not match the qualifier' do + model = define_model 'Example', attr1: :string, attr2: :string do + validates_presence_of(:attr1, allow_nil: true) + validates_presence_of(:attr2) + end + + assertion = lambda do + expect(model.new).to validate_presence_of(:attr1, :attr2).allow_nil + end + + message = <<-MESSAGE +Expected Example to validate that :attr2 cannot be empty/falsy, but this +could not be proved. + After setting :attr2 to ‹nil›, the matcher expected the Example to be + valid, but it was invalid instead, producing these validation errors: + + * attr2: ["can't be blank"] + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + end + it 'accepts' do expect(validating_presence).to matcher end