diff --git a/activemodel/lib/active_model/validations/numericality.rb b/activemodel/lib/active_model/validations/numericality.rb index aa05673683..440c49c174 100644 --- a/activemodel/lib/active_model/validations/numericality.rb +++ b/activemodel/lib/active_model/validations/numericality.rb @@ -22,7 +22,7 @@ module ActiveModel 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 @@ module ActiveModel 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 @@ module ActiveModel 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 @@ module ActiveModel 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 @@ module ActiveModel 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 ActiveModel # Validates whether the value of the specified attribute is numeric by # trying to convert it to a float with Kernel.Float (if only_integer # is +false+) or applying it to the regular expression /\A[\+\-]?\d+\z/ - # (if only_integer is set to +true+). + # (if only_integer 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 diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index aa953b0d64..5a7a3b6029 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -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 diff --git a/activerecord/lib/active_record/validations.rb b/activerecord/lib/active_record/validations.rb index 86bfb661dc..818515b4e0 100644 --- a/activerecord/lib/active_record/validations.rb +++ b/activerecord/lib/active_record/validations.rb @@ -91,3 +91,4 @@ require "active_record/validations/uniqueness" require "active_record/validations/presence" require "active_record/validations/absence" require "active_record/validations/length" +require "active_record/validations/numericality" diff --git a/activerecord/lib/active_record/validations/numericality.rb b/activerecord/lib/active_record/validations/numericality.rb new file mode 100644 index 0000000000..0df93fede1 --- /dev/null +++ b/activerecord/lib/active_record/validations/numericality.rb @@ -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 only_integer + # is +false+) or applying it to the regular expression /\A[\+\-]?\d+\z/ + # (if only_integer 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 diff --git a/activerecord/test/cases/validations/numericality_validation_test.rb b/activerecord/test/cases/validations/numericality_validation_test.rb new file mode 100644 index 0000000000..8a9a314844 --- /dev/null +++ b/activerecord/test/cases/validations/numericality_validation_test.rb @@ -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 diff --git a/activerecord/test/schema/schema.rb b/activerecord/test/schema/schema.rb index f2f95f3b2c..8a69019b9a 100644 --- a/activerecord/test/schema/schema.rb +++ b/activerecord/test/schema/schema.rb @@ -582,6 +582,7 @@ ActiveRecord::Schema.define do 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 diff --git a/guides/source/active_record_validations.md b/guides/source/active_record_validations.md index 5c86de964d..992d639e69 100644 --- a/guides/source/active_record_validations.md +++ b/guides/source/active_record_validations.md @@ -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