Skip to content

Commit

Permalink
Add in: range matcher to validate_numericality_of
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
matsales28 committed Oct 1, 2022
1 parent f029d26 commit 14379aa
Show file tree
Hide file tree
Showing 7 changed files with 336 additions and 41 deletions.
2 changes: 2 additions & 0 deletions lib/shoulda/matchers/active_model.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
require 'shoulda/matchers/active_model/numericality_matchers/odd_number_matcher'
require 'shoulda/matchers/active_model/numericality_matchers/even_number_matcher'
require 'shoulda/matchers/active_model/numericality_matchers/only_integer_matcher'
require 'shoulda/matchers/active_model/numericality_matchers/range_matcher'
require 'shoulda/matchers/active_model/numericality_matchers/submatchers'
require 'shoulda/matchers/active_model/errors'
require 'shoulda/matchers/active_model/have_secure_password_matcher'

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
require 'active_support/core_ext/module/delegation'

module Shoulda
module Matchers
module ActiveModel
Expand Down Expand Up @@ -31,6 +33,8 @@ class ComparisonMatcher < ValidationMatcher
},
}.freeze

delegate :failure_message, :failure_message_when_negated, to: :submatchers

def initialize(numericality_matcher, value, operator)
super(nil)
unless numericality_matcher.respond_to? :diff_to_compare
Expand Down Expand Up @@ -72,49 +76,24 @@ def expects_custom_validation_message?

def matches?(subject)
@subject = subject
all_bounds_correct?
end

def failure_message
last_failing_submatcher.failure_message
end

def failure_message_when_negated
last_failing_submatcher.failure_message_when_negated
submatchers.matches?(subject)
end

def comparison_description
"#{comparison_expectation} #{@value}"
end

private

def all_bounds_correct?
failing_submatchers.empty?
end

def failing_submatchers
submatchers_and_results.
select { |x| !x[:matched] }.
map { |x| x[:matcher] }
end

def last_failing_submatcher
failing_submatchers.last
end

def submatchers
@_submatchers ||=
comparison_combos.map do |diff, submatcher_method_name|
matcher = __send__(submatcher_method_name, diff, nil)
matcher.with_message(@message, values: { count: @value })
matcher
end
@_submatchers ||= NumericalityMatchers::Submatchers.new(build_submatchers)
end

def submatchers_and_results
@_submatchers_and_results ||= submatchers.map do |matcher|
{ matcher: matcher, matched: matcher.matches?(@subject) }
private

def build_submatchers
comparison_combos.map do |diff, submatcher_method_name|
matcher = __send__(submatcher_method_name, diff, nil)
matcher.with_message(@message, values: { count: @value })
matcher
end
end

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
require 'active_support/core_ext/module/delegation'

module Shoulda
module Matchers
module ActiveModel
module NumericalityMatchers
# @private
class RangeMatcher < ValidationMatcher
OPERATORS = [:>=, :<=].freeze

delegate :failure_message, to: :submatchers

def initialize(numericality_matcher, attribute, range)
super(attribute)
unless numericality_matcher.respond_to? :diff_to_compare
raise ArgumentError, 'numericality_matcher is invalid'
end

@numericality_matcher = numericality_matcher
@range = range
@attribute = attribute
end

def matches?(subject)
@subject = subject
submatchers.matches?(subject)
end

def simple_description
description = ''

if expects_strict?
description << ' strictly'
end

description +
"disallow :#{attribute} from being a number that is not " +
range_description
end

def range_description
"from #{Shoulda::Matchers::Util.inspect_range(@range)}"
end

def submatchers
@_submatchers ||= NumericalityMatchers::Submatchers.new(build_submatchers)
end

private

def build_submatchers
submatcher_combos.map do |value, operator|
build_comparison_submatcher(value, operator)
end
end

def submatcher_combos
@range.minmax.zip(OPERATORS)
end

def build_comparison_submatcher(value, operator)
NumericalityMatchers::ComparisonMatcher.new(@numericality_matcher, value, operator).
for(@attribute).
with_message(@message).
on(@context)
end
end
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
module Shoulda
module Matchers
module ActiveModel
module NumericalityMatchers
# @private
class Submatchers
def initialize(submatchers)
@submatchers = submatchers
end

def matches?(subject)
@subject = subject
failing_submatchers.empty?
end

def failure_message
last_failing_submatcher.failure_message
end

def failure_message_when_negated
last_failing_submatcher.failure_message_when_negated
end

def add(submatcher)
@submatchers << submatcher
end

def last_failing_submatcher
failing_submatchers.last
end

private

def failing_submatchers
@_failing_submatchers ||= @submatchers.reject do |submatcher|
submatcher.matches?(@subject)
end
end
end
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,33 @@ module ActiveModel
# should validate_numericality_of(:birth_day).odd
# end
#
# ##### is_in
#
# Use `is_in` to test usage of the `:in` option.
# This asserts that the attribute can take a number which is contained
# in the given range.
#
# class Person
# include ActiveModel::Model
# attr_accessor :legal_age
#
# validates_numericality_of :birth_month, in: 1..12
# end
#
# # RSpec
# RSpec.describe Person, type: :model do
# it do
# should validate_numericality_of(:birth_month).
# is_in(1..12)
# end
# end
#
# # Minitest (Shoulda)
# class PersonTest < ActiveSupport::TestCase
# should validate_numericality_of(:birth_month).
# is_in(1..12)
# end
#
# ##### with_message
#
# Use `with_message` if you are using a custom validation message.
Expand Down Expand Up @@ -426,6 +453,13 @@ def is_other_than(value)
self
end

def is_in(range)
prepare_submatcher(
NumericalityMatchers::RangeMatcher.new(self, @attribute, range),
)
self
end

def with_message(message)
@expects_custom_validation_message = true
@expected_message = message
Expand Down Expand Up @@ -457,6 +491,10 @@ def simple_description
description << "validate that :#{@attribute} looks like "
description << Shoulda::Matchers::Util.a_or_an(full_allowed_type)

if range_description.present?
description << " #{range_description}"
end

if comparison_descriptions.present?
description << " #{comparison_descriptions}"
end
Expand Down Expand Up @@ -673,6 +711,14 @@ def submatcher_comparison_descriptions
end
end

def range_description
range_submatcher = @submatchers.detect do |submatcher|
submatcher.respond_to? :range_description
end

range_submatcher&.range_description
end

def model
@subject.class
end
Expand Down
4 changes: 4 additions & 0 deletions spec/support/unit/helpers/rails_versions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,9 @@ def self.configure_example_group(example_group)
def rails_version
Tests::Version.new(Rails::VERSION::STRING)
end

def rails_oldest_version_supported
5.2
end
end
end
Loading

0 comments on commit 14379aa

Please sign in to comment.