1
0
Fork 0
mirror of https://github.com/rails/rails.git synced 2022-11-09 12:12:34 -05:00

Add ComparisonValidator to Rails to support validations between two comparable values.

We allow for compare validations in NumericalityValidator, but these
only work on numbers. There are various comparisons people may want
to validate, from dates to strings, to custom comparisons.

```
validates_comparison_of :end_date, greater_than: :start_date
```

Refactor NumericalityValidator to share module Comparison with ComparabilityValidator
* Move creating the option_value into a reusable module
* Separate COMPARE_CHECKS which support compare functions and accept values
* Move odd/even checks to NUMBER_CHECKS as they can only be run on numbers
This commit is contained in:
Rachael Wright-Munn 2020-08-02 12:10:39 -04:00
parent af9c910db7
commit 9a08a2f09c
5 changed files with 413 additions and 25 deletions

View file

@ -0,0 +1,38 @@
# frozen_string_literal: true
module ActiveModel
module Validations
module Comparability #:nodoc:
COMPARE_CHECKS = { greater_than: :>, greater_than_or_equal_to: :>=,
equal_to: :==, less_than: :<, less_than_or_equal_to: :<=,
other_than: :!= }.freeze
def option_value(record, option_value)
case option_value
when Proc
option_value.call(record)
when Symbol
record.send(option_value)
else
option_value
end
end
def error_options(value, option_value)
options.except(*COMPARE_CHECKS.keys).merge!(
count: option_value,
value: value
)
end
def error_value(record, option_value)
case option_value
when Proc
option_value(record, option_value)
else
option_value
end
end
end
end
end

View file

@ -0,0 +1,78 @@
# frozen_string_literal: true
module ActiveModel
module Validations
class ComparisonValidator < EachValidator # :nodoc:
include Comparability
def check_validity!
unless (options.keys & COMPARE_CHECKS.keys).any?
raise ArgumentError, "Expected one of :greater_than, :greater_than_or_equal_to, "\
":equal_to, :less_than, :less_than_or_equal_to, nor :other_than supplied."
end
end
def validate_each(record, attr_name, value)
options.slice(*COMPARE_CHECKS.keys).each do |option, raw_option_value|
if value.nil? || value.blank?
return record.errors.add(attr_name, :blank, **error_options(value, error_value(record, raw_option_value)))
end
unless value.send(COMPARE_CHECKS[option], option_value(record, raw_option_value))
record.errors.add(attr_name, option, **error_options(value, error_value(record, raw_option_value)))
end
rescue ArgumentError => e
record.errors.add(attr_name, e.message)
end
end
end
module HelperMethods
# Validates the value of a specified attribute fulfills all
# defined comparisons with another value, proc, or attribute.
#
# class Person < ActiveRecord::Base
# validates_comparison_of :value, greater_than: 'the sum of its parts'
# end
#
# Configuration options:
# * <tt>:message</tt> - A custom error message (default is: "failed comparison").
# * <tt>:greater_than</tt> - Specifies the value must be greater than the
# supplied value.
# * <tt>:greater_than_or_equal_to</tt> - Specifies the value must be
# greater than or equal the supplied value.
# * <tt>:equal_to</tt> - Specifies the value must be equal to the supplied
# value.
# * <tt>:less_than</tt> - Specifies the value must be less than the
# supplied value.
# * <tt>:less_than_or_equal_to</tt> - Specifies the value must be less
# than or equal the supplied value.
# * <tt>:other_than</tt> - Specifies the value must not be equal to the
# supplied value.
#
# There is also a list of default options supported by every validator:
# +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+ .
# See <tt>ActiveModel::Validations#validates</tt> for more information
#
# The validator requires at least one of the following checks be supplied.
# Each will accept a proc, value, or a symbol which corresponds to a method:
#
# * <tt>:greater_than</tt>
# * <tt>:greater_than_or_equal_to</tt>
# * <tt>:equal_to</tt>
# * <tt>:less_than</tt>
# * <tt>:less_than_or_equal_to</tt>
# * <tt>:other_than</tt>
#
# For example:
#
# class Person < ActiveRecord::Base
# validates_comparison_of :birth_date, less_than_or_equal_to: -> { Date.today }
# validates_comparison_of :preferred_name, other_than: :given_name, allow_nil: true
# end
def validates_comparison_of(*attr_names)
validates_with ComparisonValidator, _merge_attributes(attr_names)
end
end
end
end

View file

@ -5,24 +5,25 @@ require "bigdecimal/util"
module ActiveModel
module Validations
class NumericalityValidator < EachValidator # :nodoc:
CHECKS = { greater_than: :>, greater_than_or_equal_to: :>=,
equal_to: :==, less_than: :<, less_than_or_equal_to: :<=,
odd: :odd?, even: :even?, other_than: :!=, in: :in? }.freeze
include Comparability
RESERVED_OPTIONS = CHECKS.keys + [:only_integer]
RANGE_CHECKS = { in: :in? }
NUMBER_CHECKS = { odd: :odd?, even: :even? }
RESERVED_OPTIONS = COMPARE_CHECKS.keys + NUMBER_CHECKS.keys + RANGE_CHECKS.keys + [:only_integer]
INTEGER_REGEX = /\A[+-]?\d+\z/
HEXADECIMAL_REGEX = /\A[+-]?0[xX]/
def check_validity!
keys = CHECKS.keys - [:odd, :even, :in]
options.slice(*keys).each do |option, value|
options.slice(*COMPARE_CHECKS.keys).each do |option, value|
unless value.is_a?(Numeric) || value.is_a?(Proc) || value.is_a?(Symbol)
raise ArgumentError, ":#{option} must be a number, a symbol or a proc"
end
end
options.slice(:in).each do |option, value|
options.slice(*RANGE_CHECKS).each do |option, value|
unless value.is_a?(Range)
raise ArgumentError, ":#{option} must be a range"
end
@ -42,23 +43,18 @@ module ActiveModel
value = parse_as_number(value, precision, scale)
options.slice(*CHECKS.keys).each do |option, option_value|
case option
when :odd, :even
unless value.to_i.public_send(CHECKS[option])
options.slice(*RESERVED_OPTIONS).each do |option, option_value|
if NUMBER_CHECKS.keys.include? option
unless value.to_i.send(NUMBER_CHECKS[option])
record.errors.add(attr_name, option, **filtered_options(value))
end
else
case option_value
when Proc
option_value = option_value.call(record)
when Symbol
option_value = record.send(option_value)
elsif RANGE_CHECKS.keys.include? option
unless value.send(RANGE_CHECKS[option], option_value)
record.errors.add(attr_name, option, **filtered_options(value).merge!(count: option_value))
end
option_value = parse_as_number(option_value, precision, scale, option)
unless value.public_send(CHECKS[option], option_value)
elsif COMPARE_CHECKS.keys.include? option
option_value = option_as_number(record, option_value, precision, scale)
unless value.send(COMPARE_CHECKS[option], option_value)
record.errors.add(attr_name, option, **filtered_options(value).merge!(count: option_value))
end
end
@ -66,10 +62,12 @@ module ActiveModel
end
private
def parse_as_number(raw_value, precision, scale, option = nil)
if option == :in
raw_value if raw_value.is_a?(Range)
elsif raw_value.is_a?(Float)
def option_as_number(record, option_value, precision, scale)
parse_as_number(option_value(record, option_value), precision, scale)
end
def parse_as_number(raw_value, precision, scale)
if raw_value.is_a?(Float)
parse_float(raw_value, precision, scale)
elsif raw_value.is_a?(BigDecimal)
round(raw_value, scale)
@ -180,6 +178,7 @@ module ActiveModel
# supplied value.
# * <tt>:odd</tt> - Specifies the value must be an odd number.
# * <tt>:even</tt> - Specifies the value must be an even number.
# * <tt>:in</tt> - Check that the value is within a range.
#
# There is also a list of default options supported by every validator:
# +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+ .

View file

@ -0,0 +1,243 @@
# frozen_string_literal: true
require "cases/helper"
require "models/topic"
require "models/person"
class ComparisonValidationTest < ActiveModel::TestCase
def teardown
Topic.clear_validators!
end
def test_validates_comparison_with_greater_than_using_numeric
Topic.validates_comparison_of :approved, greater_than: 10
invalid!([-12, 10], "must be greater than 10")
valid!([11])
end
def test_validates_comparison_with_greater_than_using_date
date_value = Date.parse("2020-08-02")
Topic.validates_comparison_of :approved, greater_than: date_value
invalid!([
Date.parse("2019-08-03"),
Date.parse("2020-07-03"),
Date.parse("2020-08-01"),
Date.parse("2020-08-02"),
DateTime.new(2020, 8, 1, 12, 34)], "must be greater than 2020-08-02")
valid!([Date.parse("2020-08-03"), DateTime.new(2020, 8, 2, 12, 34)])
end
def test_validates_comparison_with_greater_than_using_string
Topic.validates_comparison_of :approved, greater_than: "cat"
invalid!(["ant", "cat"], "must be greater than cat")
valid!(["dog", "whale"])
end
def test_validates_comparison_with_greater_than_or_equal_to_using_numeric
Topic.validates_comparison_of :approved, greater_than_or_equal_to: 10
invalid!([-12, 5], "must be greater than or equal to 10")
valid!([11, 10])
end
def test_validates_comparison_with_greater_than_or_equal_to_using_string
Topic.validates_comparison_of :approved, greater_than_or_equal_to: "cat"
invalid!(["ant"], "must be greater than or equal to cat")
valid!(["cat", "dog", "whale"])
end
def test_validates_comparison_with_greater_than_or_equal_to_using_date
date_value = Date.parse("2020-08-02")
Topic.validates_comparison_of :approved, greater_than_or_equal_to: date_value
invalid!([
Date.parse("2019-08-03"),
Date.parse("2020-07-03"),
Date.parse("2020-08-01"),
DateTime.new(2020, 8, 1, 12, 34)], "must be greater than or equal to 2020-08-02")
valid!([Date.parse("2020-08-03"), DateTime.new(2020, 8, 2, 12, 34), Date.parse("2020-08-02")])
end
def test_validates_comparison_with_equal_to_using_numeric
Topic.validates_comparison_of :approved, equal_to: 10
invalid!([-12, 5, 11], "must be equal to 10")
valid!([10])
end
def test_validates_comparison_with_equal_to_using_date
date_value = Date.parse("2020-08-02")
Topic.validates_comparison_of :approved, equal_to: date_value
invalid!([
Date.parse("2019-08-03"),
Date.parse("2020-07-03"),
Date.parse("2020-08-01"),
DateTime.new(2020, 8, 1, 12, 34),
Date.parse("2020-08-03"),
DateTime.new(2020, 8, 2, 12, 34)], "must be equal to 2020-08-02")
valid!([Date.parse("2020-08-02"), DateTime.new(2020, 8, 2, 0, 0)])
end
def test_validates_comparison_with_less_than_using_numeric
Topic.validates_comparison_of :approved, less_than: 10
invalid!([11, 10], "must be less than 10")
valid!([-12, -5, 5])
end
def test_validates_comparison_with_less_than_using_date
date_value = Date.parse("2020-08-02")
Topic.validates_comparison_of :approved, less_than: date_value
invalid!([
Date.parse("2020-08-02"),
Date.parse("2020-08-03"),
DateTime.new(2020, 8, 2, 12, 34)], "must be less than 2020-08-02")
valid!([Date.parse("2019-08-03"),
Date.parse("2020-07-03"),
Date.parse("2020-08-01"),
DateTime.new(2020, 8, 1, 12, 34)])
end
def test_validates_comparison_with_less_than_or_equal_to_using_numeric
Topic.validates_comparison_of :approved, less_than_or_equal_to: 10
invalid!([12], "must be less than or equal to 10")
valid!([-11, 5, 10])
end
def test_validates_comparison_with_less_than_or_equal_to_using_date
date_value = Date.parse("2020-08-02")
Topic.validates_comparison_of :approved, less_than_or_equal_to: date_value
invalid!([
Date.parse("2020-08-03"),
DateTime.new(2020, 8, 2, 12, 34)], "must be less than or equal to 2020-08-02")
valid!([Date.parse("2019-08-03"),
Date.parse("2020-07-03"),
Date.parse("2020-08-01"),
Date.parse("2020-08-02"),
DateTime.new(2020, 8, 1, 12, 34)])
end
def test_validates_comparison_with_other_than_using_numeric
Topic.validates_comparison_of :approved, other_than: 10
invalid!([10], "must be other than 10")
valid!([-12, 5, 11])
end
def test_validates_comparison_with_other_than_using_date
date_value = Date.parse("2020-08-02")
Topic.validates_comparison_of :approved, other_than: date_value
invalid!([Date.parse("2020-08-02"), DateTime.new(2020, 8, 2, 0, 0)], "must be other than 2020-08-02")
valid!([
Date.parse("2019-08-03"),
Date.parse("2020-07-03"),
Date.parse("2020-08-01"),
DateTime.new(2020, 8, 1, 12, 34),
Date.parse("2020-08-03"),
DateTime.new(2020, 8, 2, 12, 34)])
end
def test_validates_comparison_with_proc
Topic.define_method(:requested) { Date.new(2020, 8, 1) }
Topic.validates_comparison_of :approved, greater_than_or_equal_to: Proc.new(&:requested)
invalid!([Date.new(2020, 7, 1), Date.new(2019, 7, 1), DateTime.new(2020, 7, 1, 22, 34)])
valid!([Date.new(2020, 8, 2), DateTime.new(2021, 8, 1)])
ensure
Topic.remove_method :requested
end
def test_validates_comparison_with_method
Topic.define_method(:requested) { Date.new(2020, 8, 1) }
Topic.validates_comparison_of :approved, greater_than_or_equal_to: :requested
invalid!([Date.new(2020, 7, 1), Date.new(2019, 7, 1), DateTime.new(2020, 7, 1, 22, 34)])
valid!([Date.new(2020, 8, 2), DateTime.new(2021, 8, 1)])
ensure
Topic.remove_method :requested
end
def test_validates_comparison_with_custom_compare
custom = Struct.new(:amount) {
include Comparable
def <=>(other)
amount % 100 <=> other.amount % 100
end
}
Topic.validates_comparison_of :approved, greater_than_or_equal_to: custom.new(1150)
invalid!([custom.new(530), custom.new(2325)])
valid!([custom.new(575), custom.new(250), custom.new(1999)])
end
def test_validates_comparison_with_blank_allowed
Topic.validates_comparison_of :approved, greater_than: "cat", allow_blank: true
invalid!(["ant"])
valid!([nil, ""])
end
def test_validates_comparison_with_nil_allowed
Topic.validates_comparison_of :approved, less_than: 100, allow_nil: true
invalid!([200])
valid!([nil, 50])
end
def test_validates_comparison_of_incomparables
Topic.validates_comparison_of :approved, less_than: "cat"
invalid!([12], "comparison of Integer with String failed")
invalid!([nil])
valid!([])
end
def test_validates_comparison_of_multiple_values
Topic.validates_comparison_of :approved, other_than: 17, greater_than: 13
invalid!([12, nil, 17])
valid!([15])
end
def test_validates_comparison_of_no_options
error = assert_raises(ArgumentError) do
Topic.validates_comparison_of(:approved)
end
assert_equal "Expected one of :greater_than, :greater_than_or_equal_to, :equal_to," \
" :less_than, :less_than_or_equal_to, nor :other_than supplied.", error.message
end
private
def invalid!(values, error = nil)
with_each_topic_approved_value(values) do |topic, value|
assert topic.invalid?, "#{value.inspect} failed comparison"
assert topic.errors[:approved].any?, "FAILED for #{value.inspect}"
assert_equal error, topic.errors[:approved].first if error
end
end
def valid!(values)
with_each_topic_approved_value(values) do |topic, value|
assert topic.valid?, "#{value.inspect} failed comparison with validation error: #{topic.errors[:approved].first}"
end
end
def with_each_topic_approved_value(values)
topic = Topic.new(title: "comparison test", content: "whatever")
values.each do |value|
topic.approved = value
yield topic, value
end
end
end

View file

@ -387,6 +387,36 @@ end
The default error message for this helper is _"doesn't match confirmation"_.
### `comparison`
This check will validate a comparison between any two comparable values.
The validator requires a compare option be supplied. Each option accepts a
value, proc, or symbol. Any class that includes Comparable can be compared.
```ruby
class Promotion < ApplicationRecord
validates :start_date, comparison: { greater_than: :end_date }
end
```
These options are all supported:
* `:greater_than` - Specifies the value must be greater than the supplied
value. The default error message for this option is _"must be greater than
%{count}"_.
* `:greater_than_or_equal_to` - Specifies the value must be greater than or
equal to the supplied value. The default error message for this option is
_"must be greater than or equal to %{count}"_.
* `:equal_to` - Specifies the value must be equal to the supplied value. The
default error message for this option is _"must be equal to %{count}"_.
* `:less_than` - Specifies the value must be less than the supplied value. The
default error message for this option is _"must be less than %{count}"_.
* `:less_than_or_equal_to` - Specifies the value must be less than or equal to
the supplied value. The default error message for this option is _"must be
less than or equal to %{count}"_.
* `:other_than` - Specifies the value must be other than the supplied value.
The default error message for this option is _"must be other than %{count}"_.
### `exclusion`
This helper validates that the attributes' values are not included in a given