Skip to content

Commit 22c7f16

Browse files
committed
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 ```
1 parent 8890f3d commit 22c7f16

File tree

7 files changed

+139
-4
lines changed

7 files changed

+139
-4
lines changed

lib/shoulda/matchers/active_model.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
require 'shoulda/matchers/active_model/helpers'
22
require 'shoulda/matchers/active_model/qualifiers'
3+
require 'shoulda/matchers/active_model/matcher_collection'
34
require 'shoulda/matchers/active_model/validation_matcher'
45
require 'shoulda/matchers/active_model/validation_matcher/build_description'
56
require 'shoulda/matchers/active_model/validator'

lib/shoulda/matchers/active_model/allow_value_matcher.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -458,7 +458,7 @@ def failure_message
458458
message << '.'
459459
else
460460
message << " producing these validation errors:\n\n"
461-
message << validator.all_formatted_validation_error_messages
461+
message << validator.formatted_validation_error_messages
462462
end
463463
end
464464

lib/shoulda/matchers/active_model/helpers.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ def pretty_error_messages(object)
77
format_validation_errors(object.errors)
88
end
99

10-
def format_validation_errors(errors)
10+
def format_validation_errors(errors, attr = nil)
1111
list_items = errors.to_hash.keys.map do |attribute|
12+
next if attr && attr.to_sym != attribute.to_sym
13+
1214
messages = errors[attribute]
1315
"* #{attribute}: #{messages}"
1416
end
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
module Shoulda
2+
module Matchers
3+
module ActiveModel
4+
# @private
5+
class MatcherCollection
6+
def initialize(matchers)
7+
@matchers = matchers
8+
end
9+
10+
def description
11+
matchers.map(&:description).join(' and ')
12+
end
13+
14+
def matches?(subject)
15+
@failed_matchers = failed_matchers_for(subject, :matches?)
16+
@failed_matchers.empty?
17+
end
18+
19+
def does_not_match?(subject)
20+
@failed_matchers = failed_matchers_for(subject, :does_not_match?)
21+
@failed_matchers.empty?
22+
end
23+
24+
def failure_message
25+
first_failure_message(:failure_message)
26+
end
27+
28+
def failure_message_when_negated
29+
first_failure_message(:failure_message_when_negated)
30+
end
31+
32+
def method_missing(method, *args, &block)
33+
if all_matchers_respond_to?(method)
34+
matchers.each { |matcher| matcher.send(method, *args, &block) }
35+
self
36+
else
37+
super
38+
end
39+
end
40+
41+
def respond_to_missing?(method, include_private = false)
42+
all_matchers_respond_to?(method) || super
43+
end
44+
45+
private
46+
47+
attr_reader :matchers
48+
49+
def failed_matchers_for(subject, method)
50+
matchers.reject { |matcher| matcher.send(method, subject) }
51+
end
52+
53+
def first_failure_message(method)
54+
@failed_matchers.first&.send(method)
55+
end
56+
57+
def all_matchers_respond_to?(method)
58+
matchers.all? { |matcher| matcher.respond_to?(method) }
59+
end
60+
end
61+
end
62+
end
63+
end

lib/shoulda/matchers/active_model/validate_presence_of_matcher.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,10 @@ module ActiveModel
147147
#
148148
# @return [ValidatePresenceOfMatcher]
149149
#
150-
def validate_presence_of(attr)
151-
ValidatePresenceOfMatcher.new(attr)
150+
151+
def validate_presence_of(*attrs)
152+
matchers = attrs.map { |attr| ValidatePresenceOfMatcher.new(attr) }
153+
MatcherCollection.new(matchers)
152154
end
153155

154156
# @private

lib/shoulda/matchers/active_model/validator.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ def validation_exception_message
4545
validation_result[:validation_exception_message]
4646
end
4747

48+
def formatted_validation_error_messages
49+
format_validation_errors(all_validation_errors, attribute)
50+
end
51+
4852
protected
4953

5054
attr_reader :attribute, :context, :record

spec/unit/shoulda/matchers/active_model/validate_presence_of_matcher_spec.rb

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,69 @@
44
include UnitTests::ApplicationConfigurationHelpers
55

66
context 'a model with a presence validation' do
7+
context 'passing multiple attributes' do
8+
it 'accepts' do
9+
model = define_model 'Example', attr1: :string, attr2: :string do
10+
validates_presence_of(:attr1)
11+
validates_presence_of(:attr2)
12+
end
13+
14+
expect(model.new).to validate_presence_of(:attr1, :attr2)
15+
end
16+
17+
it 'fails when used in the negative' do
18+
model = define_model 'Example', attr1: :string, attr2: :string do
19+
validates_presence_of(:attr1)
20+
end
21+
22+
assertion = lambda do
23+
expect(model.new).not_to validate_presence_of(:attr1, :attr2)
24+
end
25+
26+
message = <<-MESSAGE
27+
Expected Example not to validate that :attr1 cannot be empty/falsy, but
28+
this could not be proved.
29+
After setting :attr1 to ‹nil›, the matcher expected the Example to be
30+
valid, but it was invalid instead, producing these validation errors:
31+
32+
* attr1: ["can't be blank"]
33+
MESSAGE
34+
35+
expect(&assertion).to fail_with_message(message)
36+
end
37+
38+
it 'accepts when using qualifiers' do
39+
model = define_model 'Example', attr1: :string, attr2: :string do
40+
validates_presence_of(:attr1, allow_nil: true)
41+
validates_presence_of(:attr2, allow_nil: true)
42+
end
43+
44+
expect(model.new).to validate_presence_of(:attr1, :attr2).allow_nil
45+
end
46+
47+
it 'rejects when one attribute does not match the qualifier' do
48+
model = define_model 'Example', attr1: :string, attr2: :string do
49+
validates_presence_of(:attr1, allow_nil: true)
50+
validates_presence_of(:attr2)
51+
end
52+
53+
assertion = lambda do
54+
expect(model.new).to validate_presence_of(:attr1, :attr2).allow_nil
55+
end
56+
57+
message = <<-MESSAGE
58+
Expected Example to validate that :attr2 cannot be empty/falsy, but this
59+
could not be proved.
60+
After setting :attr2 to ‹nil›, the matcher expected the Example to be
61+
valid, but it was invalid instead, producing these validation errors:
62+
63+
* attr2: ["can't be blank"]
64+
MESSAGE
65+
66+
expect(&assertion).to fail_with_message(message)
67+
end
68+
end
69+
770
it 'accepts' do
871
expect(validating_presence).to matcher
972
end

0 commit comments

Comments
 (0)