Add ActiveRecord::Validations::NumericalityValidator

Add Active Record numericality validator with support for casting
floats using a database columns' precision value.
This commit is contained in:
Gannon McGibbon 2019-07-12 13:57:30 -04:00
parent f619ac91d3
commit b5c9974fa7
7 changed files with 103 additions and 15 deletions

@ -22,7 +22,7 @@ def check_validity!
end
end
def validate_each(record, attr_name, value)
def validate_each(record, attr_name, value, precision: Float::DIG)
came_from_user = :"#{attr_name}_came_from_user?"
if record.respond_to?(came_from_user)
@ -43,7 +43,7 @@ def validate_each(record, attr_name, value)
raw_value = value
end
unless is_number?(raw_value)
unless is_number?(raw_value, precision)
record.errors.add(attr_name, :not_a_number, **filtered_options(raw_value))
return
end
@ -53,7 +53,7 @@ def validate_each(record, attr_name, value)
return
end
value = parse_as_number(raw_value)
value = parse_as_number(raw_value, precision)
options.slice(*CHECKS.keys).each do |option, option_value|
case option
@ -69,7 +69,7 @@ def validate_each(record, attr_name, value)
option_value = record.send(option_value)
end
option_value = parse_as_number(option_value)
option_value = parse_as_number(option_value, precision)
unless value.send(CHECKS[option], option_value)
record.errors.add(attr_name, option, **filtered_options(value).merge!(count: option_value))
@ -79,24 +79,24 @@ def validate_each(record, attr_name, value)
end
private
def is_number?(raw_value)
!parse_as_number(raw_value).nil?
rescue ArgumentError, TypeError
false
end
def parse_as_number(raw_value)
def parse_as_number(raw_value, precision)
if raw_value.is_a?(Float)
raw_value.to_d
raw_value.to_d(precision)
elsif raw_value.is_a?(Numeric)
raw_value
elsif is_integer?(raw_value)
raw_value.to_i
elsif !is_hexadecimal_literal?(raw_value)
Kernel.Float(raw_value).to_d
Kernel.Float(raw_value).to_d(precision)
end
end
def is_number?(raw_value, precision)
!parse_as_number(raw_value, precision).nil?
rescue ArgumentError, TypeError
false
end
def is_integer?(raw_value)
INTEGER_REGEX.match?(raw_value.to_s)
end
@ -132,7 +132,8 @@ module HelperMethods
# Validates whether the value of the specified attribute is numeric by
# trying to convert it to a float with Kernel.Float (if <tt>only_integer</tt>
# is +false+) or applying it to the regular expression <tt>/\A[\+\-]?\d+\z/</tt>
# (if <tt>only_integer</tt> is set to +true+).
# (if <tt>only_integer</tt> is set to +true+). Precision of Kernel.Float values
# are guaranteed up to 15 digits.
#
# class Person < ActiveRecord::Base
# validates_numericality_of :value, on: :create

@ -1,3 +1,8 @@
* Add `ActiveRecord::Validations::NumericalityValidator` with
support for casting floats using a database columns' precision value.
*Gannon McGibbon*
* Enforce fresh ETag header after a collection's contents change by adding
ActiveRecord::Relation#cache_key_with_version. This method will be used by
ActionController::ConditionalGet to ensure that when collection cache versioning

@ -91,3 +91,4 @@ def perform_validations(options = {})
require "active_record/validations/presence"
require "active_record/validations/absence"
require "active_record/validations/length"
require "active_record/validations/numericality"

@ -0,0 +1,37 @@
# frozen_string_literal: true
module ActiveRecord
module Validations
class NumericalityValidator < ActiveModel::Validations::NumericalityValidator # :nodoc:
def initialize(options)
super
@klass = options[:class]
end
def validate_each(record, attribute, value, precision: nil)
precision = column_precision_for(attribute) || Float::DIG
super
end
private
def column_precision_for(attribute)
if @klass < ActiveRecord::Base
@klass.type_for_attribute(attribute.to_s)&.precision
end
end
end
module ClassMethods
# Validates whether the value of the specified attribute is numeric by
# trying to convert it to a float with Kernel.Float (if <tt>only_integer</tt>
# is +false+) or applying it to the regular expression <tt>/\A[\+\-]?\d+\z/</tt>
# (if <tt>only_integer</tt> is set to +true+). Kernel.Float precision
# defaults to the column's precision value or 15.
#
# See ActiveModel::Validations::HelperMethods.validates_numericality_of for more information.
def validates_numericality_of(*attr_names)
validates_with NumericalityValidator, _merge_attributes(attr_names)
end
end
end
end

@ -0,0 +1,43 @@
# frozen_string_literal: true
require "cases/helper"
require "models/numeric_data"
class NumericalityValidationTest < ActiveRecord::TestCase
def setup
@model_class = NumericData.dup
end
attr_reader :model_class
def test_column_with_precision
model_class.validates_numericality_of(
:bank_balance, equal_to: 10_000_000.12
)
subject = model_class.new(bank_balance: 10_000_000.121)
assert_predicate subject, :valid?
end
def test_no_column_precision
model_class.validates_numericality_of(
:decimal_number, equal_to: 1_000_000_000.12345
)
subject = model_class.new(decimal_number: 1_000_000_000.123454)
assert_predicate subject, :valid?
end
def test_virtual_attribute
model_class.attribute(:virtual_decimal_number, :decimal)
model_class.validates_numericality_of(
:virtual_decimal_number, equal_to: 1_000_000_000.12345
)
subject = model_class.new(virtual_decimal_number: 1_000_000_000.123454)
assert_predicate subject, :valid?
end
end

@ -582,6 +582,7 @@
t.decimal :big_bank_balance, precision: 15, scale: 2
t.decimal :world_population, precision: 20, scale: 0
t.decimal :my_house_population, precision: 2, scale: 0
t.decimal :decimal_number
t.decimal :decimal_number_with_default, precision: 3, scale: 2, default: 2.78
t.float :temperature
# Oracle/SQLServer supports precision up to 38

@ -487,7 +487,7 @@ If you set `:only_integer` to `true`, then it will use the
```
regular expression to validate the attribute's value. Otherwise, it will try to
convert the value to a number using `Float`.
convert the value to a number using `Float`. `Float`s are casted to `BigDecimal` using the column's precision value or 15.
```ruby
class Player < ApplicationRecord