Skip to content

Commit 09bc260

Browse files
authored
Add in: range matcher to validate_numericality_of (#1512)
Closes: #1493 In Rails 7 was added a new option to validate numericality. You can use `in: range` to specify a range to validate an attribute. ```ruby class User < ApplicationRecord validates :age, numericality: { greater_than_or_equal_to: 18, less_than_or_equal_to: 65 } end class User < ApplicationRecord validates :age, numericality: { in: 18..65 } end ``` In this commit we are adding the support matcher to this new functionality, while also making a refactor on the numericality matchers that use the concept of submatchers. We've created a new class (`NumericalityMatchers::Submatcher`) that's been used by `NumericalityMatchers::RangeMatcher` and `NumericalityMatchers::ComparisonMatcher`, this new class wil handle shared logic regarding having submatchers that will check if the parent matcher is valid or not. Our new class `Numericality::Matchers::RangeMatcher` is using as submatchers two `NumericalityMatchers::ComparisonMatcher` instances to avoid creating new logic to handle this new option and also to replicate what was being used before this option existed in Rails (see example above) In this commit we are adding: * NumericalityMatchers::RangeMatcher file to support the new `in: range` option. * Specs on ValidateNumericalityOfMatcherSpec file for the new supported option, only running on rails_versions > 7. * NumericalityMatchers::Submatchers file to handle having submatchers inside a matcher file. * Refactors to NumericalityMatchers::ComparisonMatcher.
1 parent 1b949d1 commit 09bc260

File tree

7 files changed

+336
-41
lines changed

7 files changed

+336
-41
lines changed

lib/shoulda/matchers/active_model.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
require 'shoulda/matchers/active_model/numericality_matchers/odd_number_matcher'
2727
require 'shoulda/matchers/active_model/numericality_matchers/even_number_matcher'
2828
require 'shoulda/matchers/active_model/numericality_matchers/only_integer_matcher'
29+
require 'shoulda/matchers/active_model/numericality_matchers/range_matcher'
30+
require 'shoulda/matchers/active_model/numericality_matchers/submatchers'
2931
require 'shoulda/matchers/active_model/errors'
3032
require 'shoulda/matchers/active_model/have_secure_password_matcher'
3133

lib/shoulda/matchers/active_model/numericality_matchers/comparison_matcher.rb

Lines changed: 13 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
require 'active_support/core_ext/module/delegation'
2+
13
module Shoulda
24
module Matchers
35
module ActiveModel
@@ -31,6 +33,8 @@ class ComparisonMatcher < ValidationMatcher
3133
},
3234
}.freeze
3335

36+
delegate :failure_message, :failure_message_when_negated, to: :submatchers
37+
3438
def initialize(numericality_matcher, value, operator)
3539
super(nil)
3640
unless numericality_matcher.respond_to? :diff_to_compare
@@ -72,49 +76,24 @@ def expects_custom_validation_message?
7276

7377
def matches?(subject)
7478
@subject = subject
75-
all_bounds_correct?
76-
end
77-
78-
def failure_message
79-
last_failing_submatcher.failure_message
80-
end
81-
82-
def failure_message_when_negated
83-
last_failing_submatcher.failure_message_when_negated
79+
submatchers.matches?(subject)
8480
end
8581

8682
def comparison_description
8783
"#{comparison_expectation} #{@value}"
8884
end
8985

90-
private
91-
92-
def all_bounds_correct?
93-
failing_submatchers.empty?
94-
end
95-
96-
def failing_submatchers
97-
submatchers_and_results.
98-
select { |x| !x[:matched] }.
99-
map { |x| x[:matcher] }
100-
end
101-
102-
def last_failing_submatcher
103-
failing_submatchers.last
104-
end
105-
10686
def submatchers
107-
@_submatchers ||=
108-
comparison_combos.map do |diff, submatcher_method_name|
109-
matcher = __send__(submatcher_method_name, diff, nil)
110-
matcher.with_message(@message, values: { count: @value })
111-
matcher
112-
end
87+
@_submatchers ||= NumericalityMatchers::Submatchers.new(build_submatchers)
11388
end
11489

115-
def submatchers_and_results
116-
@_submatchers_and_results ||= submatchers.map do |matcher|
117-
{ matcher: matcher, matched: matcher.matches?(@subject) }
90+
private
91+
92+
def build_submatchers
93+
comparison_combos.map do |diff, submatcher_method_name|
94+
matcher = __send__(submatcher_method_name, diff, nil)
95+
matcher.with_message(@message, values: { count: @value })
96+
matcher
11897
end
11998
end
12099

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
require 'active_support/core_ext/module/delegation'
2+
3+
module Shoulda
4+
module Matchers
5+
module ActiveModel
6+
module NumericalityMatchers
7+
# @private
8+
class RangeMatcher < ValidationMatcher
9+
OPERATORS = [:>=, :<=].freeze
10+
11+
delegate :failure_message, to: :submatchers
12+
13+
def initialize(numericality_matcher, attribute, range)
14+
super(attribute)
15+
unless numericality_matcher.respond_to? :diff_to_compare
16+
raise ArgumentError, 'numericality_matcher is invalid'
17+
end
18+
19+
@numericality_matcher = numericality_matcher
20+
@range = range
21+
@attribute = attribute
22+
end
23+
24+
def matches?(subject)
25+
@subject = subject
26+
submatchers.matches?(subject)
27+
end
28+
29+
def simple_description
30+
description = ''
31+
32+
if expects_strict?
33+
description << ' strictly'
34+
end
35+
36+
description +
37+
"disallow :#{attribute} from being a number that is not " +
38+
range_description
39+
end
40+
41+
def range_description
42+
"from #{Shoulda::Matchers::Util.inspect_range(@range)}"
43+
end
44+
45+
def submatchers
46+
@_submatchers ||= NumericalityMatchers::Submatchers.new(build_submatchers)
47+
end
48+
49+
private
50+
51+
def build_submatchers
52+
submatcher_combos.map do |value, operator|
53+
build_comparison_submatcher(value, operator)
54+
end
55+
end
56+
57+
def submatcher_combos
58+
@range.minmax.zip(OPERATORS)
59+
end
60+
61+
def build_comparison_submatcher(value, operator)
62+
NumericalityMatchers::ComparisonMatcher.new(@numericality_matcher, value, operator).
63+
for(@attribute).
64+
with_message(@message).
65+
on(@context)
66+
end
67+
end
68+
end
69+
end
70+
end
71+
end
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
module Shoulda
2+
module Matchers
3+
module ActiveModel
4+
module NumericalityMatchers
5+
# @private
6+
class Submatchers
7+
def initialize(submatchers)
8+
@submatchers = submatchers
9+
end
10+
11+
def matches?(subject)
12+
@subject = subject
13+
failing_submatchers.empty?
14+
end
15+
16+
def failure_message
17+
last_failing_submatcher.failure_message
18+
end
19+
20+
def failure_message_when_negated
21+
last_failing_submatcher.failure_message_when_negated
22+
end
23+
24+
def add(submatcher)
25+
@submatchers << submatcher
26+
end
27+
28+
def last_failing_submatcher
29+
failing_submatchers.last
30+
end
31+
32+
private
33+
34+
def failing_submatchers
35+
@_failing_submatchers ||= @submatchers.reject do |submatcher|
36+
submatcher.matches?(@subject)
37+
end
38+
end
39+
end
40+
end
41+
end
42+
end
43+
end

lib/shoulda/matchers/active_model/validate_numericality_of_matcher.rb

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,33 @@ module ActiveModel
276276
# should validate_numericality_of(:birth_day).odd
277277
# end
278278
#
279+
# ##### is_in
280+
#
281+
# Use `is_in` to test usage of the `:in` option.
282+
# This asserts that the attribute can take a number which is contained
283+
# in the given range.
284+
#
285+
# class Person
286+
# include ActiveModel::Model
287+
# attr_accessor :legal_age
288+
#
289+
# validates_numericality_of :birth_month, in: 1..12
290+
# end
291+
#
292+
# # RSpec
293+
# RSpec.describe Person, type: :model do
294+
# it do
295+
# should validate_numericality_of(:birth_month).
296+
# is_in(1..12)
297+
# end
298+
# end
299+
#
300+
# # Minitest (Shoulda)
301+
# class PersonTest < ActiveSupport::TestCase
302+
# should validate_numericality_of(:birth_month).
303+
# is_in(1..12)
304+
# end
305+
#
279306
# ##### with_message
280307
#
281308
# Use `with_message` if you are using a custom validation message.
@@ -426,6 +453,13 @@ def is_other_than(value)
426453
self
427454
end
428455

456+
def is_in(range)
457+
prepare_submatcher(
458+
NumericalityMatchers::RangeMatcher.new(self, @attribute, range),
459+
)
460+
self
461+
end
462+
429463
def with_message(message)
430464
@expects_custom_validation_message = true
431465
@expected_message = message
@@ -457,6 +491,10 @@ def simple_description
457491
description << "validate that :#{@attribute} looks like "
458492
description << Shoulda::Matchers::Util.a_or_an(full_allowed_type)
459493

494+
if range_description.present?
495+
description << " #{range_description}"
496+
end
497+
460498
if comparison_descriptions.present?
461499
description << " #{comparison_descriptions}"
462500
end
@@ -673,6 +711,14 @@ def submatcher_comparison_descriptions
673711
end
674712
end
675713

714+
def range_description
715+
range_submatcher = @submatchers.detect do |submatcher|
716+
submatcher.respond_to? :range_description
717+
end
718+
719+
range_submatcher&.range_description
720+
end
721+
676722
def model
677723
@subject.class
678724
end

spec/support/unit/helpers/rails_versions.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,9 @@ def self.configure_example_group(example_group)
1010
def rails_version
1111
Tests::Version.new(Rails::VERSION::STRING)
1212
end
13+
14+
def rails_oldest_version_supported
15+
5.2
16+
end
1317
end
1418
end

0 commit comments

Comments
 (0)