omg-activemodel 8.0.0.alpha1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (77) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +67 -0
  3. data/MIT-LICENSE +21 -0
  4. data/README.rdoc +266 -0
  5. data/lib/active_model/access.rb +16 -0
  6. data/lib/active_model/api.rb +99 -0
  7. data/lib/active_model/attribute/user_provided_default.rb +55 -0
  8. data/lib/active_model/attribute.rb +277 -0
  9. data/lib/active_model/attribute_assignment.rb +78 -0
  10. data/lib/active_model/attribute_methods.rb +592 -0
  11. data/lib/active_model/attribute_mutation_tracker.rb +189 -0
  12. data/lib/active_model/attribute_registration.rb +117 -0
  13. data/lib/active_model/attribute_set/builder.rb +182 -0
  14. data/lib/active_model/attribute_set/yaml_encoder.rb +40 -0
  15. data/lib/active_model/attribute_set.rb +118 -0
  16. data/lib/active_model/attributes.rb +165 -0
  17. data/lib/active_model/callbacks.rb +155 -0
  18. data/lib/active_model/conversion.rb +121 -0
  19. data/lib/active_model/deprecator.rb +7 -0
  20. data/lib/active_model/dirty.rb +416 -0
  21. data/lib/active_model/error.rb +208 -0
  22. data/lib/active_model/errors.rb +547 -0
  23. data/lib/active_model/forbidden_attributes_protection.rb +33 -0
  24. data/lib/active_model/gem_version.rb +17 -0
  25. data/lib/active_model/lint.rb +118 -0
  26. data/lib/active_model/locale/en.yml +38 -0
  27. data/lib/active_model/model.rb +78 -0
  28. data/lib/active_model/naming.rb +359 -0
  29. data/lib/active_model/nested_error.rb +22 -0
  30. data/lib/active_model/railtie.rb +24 -0
  31. data/lib/active_model/secure_password.rb +231 -0
  32. data/lib/active_model/serialization.rb +198 -0
  33. data/lib/active_model/serializers/json.rb +154 -0
  34. data/lib/active_model/translation.rb +78 -0
  35. data/lib/active_model/type/big_integer.rb +36 -0
  36. data/lib/active_model/type/binary.rb +62 -0
  37. data/lib/active_model/type/boolean.rb +48 -0
  38. data/lib/active_model/type/date.rb +78 -0
  39. data/lib/active_model/type/date_time.rb +88 -0
  40. data/lib/active_model/type/decimal.rb +107 -0
  41. data/lib/active_model/type/float.rb +64 -0
  42. data/lib/active_model/type/helpers/accepts_multiparameter_time.rb +53 -0
  43. data/lib/active_model/type/helpers/mutable.rb +24 -0
  44. data/lib/active_model/type/helpers/numeric.rb +61 -0
  45. data/lib/active_model/type/helpers/time_value.rb +127 -0
  46. data/lib/active_model/type/helpers/timezone.rb +23 -0
  47. data/lib/active_model/type/helpers.rb +7 -0
  48. data/lib/active_model/type/immutable_string.rb +71 -0
  49. data/lib/active_model/type/integer.rb +113 -0
  50. data/lib/active_model/type/registry.rb +37 -0
  51. data/lib/active_model/type/serialize_cast_value.rb +47 -0
  52. data/lib/active_model/type/string.rb +43 -0
  53. data/lib/active_model/type/time.rb +87 -0
  54. data/lib/active_model/type/value.rb +157 -0
  55. data/lib/active_model/type.rb +55 -0
  56. data/lib/active_model/validations/absence.rb +33 -0
  57. data/lib/active_model/validations/acceptance.rb +113 -0
  58. data/lib/active_model/validations/callbacks.rb +119 -0
  59. data/lib/active_model/validations/clusivity.rb +54 -0
  60. data/lib/active_model/validations/comparability.rb +18 -0
  61. data/lib/active_model/validations/comparison.rb +90 -0
  62. data/lib/active_model/validations/confirmation.rb +80 -0
  63. data/lib/active_model/validations/exclusion.rb +49 -0
  64. data/lib/active_model/validations/format.rb +112 -0
  65. data/lib/active_model/validations/helper_methods.rb +15 -0
  66. data/lib/active_model/validations/inclusion.rb +47 -0
  67. data/lib/active_model/validations/length.rb +130 -0
  68. data/lib/active_model/validations/numericality.rb +222 -0
  69. data/lib/active_model/validations/presence.rb +39 -0
  70. data/lib/active_model/validations/resolve_value.rb +26 -0
  71. data/lib/active_model/validations/validates.rb +175 -0
  72. data/lib/active_model/validations/with.rb +154 -0
  73. data/lib/active_model/validations.rb +489 -0
  74. data/lib/active_model/validator.rb +190 -0
  75. data/lib/active_model/version.rb +10 -0
  76. data/lib/active_model.rb +84 -0
  77. metadata +139 -0
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveModel
4
+ module Validations
5
+ class ConfirmationValidator < EachValidator # :nodoc:
6
+ def initialize(options)
7
+ super({ case_sensitive: true }.merge!(options))
8
+ setup!(options[:class])
9
+ end
10
+
11
+ def validate_each(record, attribute, value)
12
+ unless (confirmed = record.public_send("#{attribute}_confirmation")).nil?
13
+ unless confirmation_value_equal?(record, attribute, value, confirmed)
14
+ human_attribute_name = record.class.human_attribute_name(attribute)
15
+ record.errors.add(:"#{attribute}_confirmation", :confirmation, **options.except(:case_sensitive).merge!(attribute: human_attribute_name))
16
+ end
17
+ end
18
+ end
19
+
20
+ private
21
+ def setup!(klass)
22
+ klass.attr_reader(*attributes.filter_map do |attribute|
23
+ :"#{attribute}_confirmation" unless klass.method_defined?(:"#{attribute}_confirmation")
24
+ end)
25
+
26
+ klass.attr_writer(*attributes.filter_map do |attribute|
27
+ :"#{attribute}_confirmation" unless klass.method_defined?(:"#{attribute}_confirmation=")
28
+ end)
29
+ end
30
+
31
+ def confirmation_value_equal?(record, attribute, value, confirmed)
32
+ if !options[:case_sensitive] && value.is_a?(String)
33
+ value.casecmp(confirmed) == 0
34
+ else
35
+ value == confirmed
36
+ end
37
+ end
38
+ end
39
+
40
+ module HelperMethods
41
+ # Encapsulates the pattern of wanting to validate a password or email
42
+ # address field with a confirmation.
43
+ #
44
+ # Model:
45
+ # class Person < ActiveRecord::Base
46
+ # validates_confirmation_of :user_name, :password
47
+ # validates_confirmation_of :email_address,
48
+ # message: 'should match confirmation'
49
+ # end
50
+ #
51
+ # View:
52
+ # <%= password_field "person", "password" %>
53
+ # <%= password_field "person", "password_confirmation" %>
54
+ #
55
+ # The added +password_confirmation+ attribute is virtual; it exists only
56
+ # as an in-memory attribute for validating the password. To achieve this,
57
+ # the validation adds accessors to the model for the confirmation
58
+ # attribute.
59
+ #
60
+ # NOTE: This check is performed only if +password_confirmation+ is not
61
+ # +nil+. To require confirmation, make sure to add a presence check for
62
+ # the confirmation attribute:
63
+ #
64
+ # validates_presence_of :password_confirmation, if: :password_changed?
65
+ #
66
+ # Configuration options:
67
+ # * <tt>:message</tt> - A custom error message (default is: "doesn't match
68
+ # <tt>%{translated_attribute_name}</tt>").
69
+ # * <tt>:case_sensitive</tt> - Looks for an exact match. Ignored by
70
+ # non-text columns (+true+ by default).
71
+ #
72
+ # There is also a list of default options supported by every validator:
73
+ # +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+.
74
+ # See ActiveModel::Validations::ClassMethods#validates for more information.
75
+ def validates_confirmation_of(*attr_names)
76
+ validates_with ConfirmationValidator, _merge_attributes(attr_names)
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model/validations/clusivity"
4
+
5
+ module ActiveModel
6
+ module Validations
7
+ class ExclusionValidator < EachValidator # :nodoc:
8
+ include Clusivity
9
+
10
+ def validate_each(record, attribute, value)
11
+ if include?(record, value)
12
+ record.errors.add(attribute, :exclusion, **options.except(:in, :within).merge!(value: value))
13
+ end
14
+ end
15
+ end
16
+
17
+ module HelperMethods
18
+ # Validates that the value of the specified attribute is not in a
19
+ # particular enumerable object.
20
+ #
21
+ # class Person < ActiveRecord::Base
22
+ # validates_exclusion_of :username, in: %w( admin superuser ), message: "You don't belong here"
23
+ # validates_exclusion_of :age, in: 30..60, message: 'This site is only for under 30 and over 60'
24
+ # validates_exclusion_of :format, in: %w( mov avi ), message: "extension %{value} is not allowed"
25
+ # validates_exclusion_of :password, in: ->(person) { [person.username, person.first_name] },
26
+ # message: 'should not be the same as your username or first name'
27
+ # validates_exclusion_of :karma, in: :reserved_karmas
28
+ # end
29
+ #
30
+ # Configuration options:
31
+ # * <tt>:in</tt> - An enumerable object of items that the value shouldn't
32
+ # be part of. This can be supplied as a proc, lambda, or symbol which returns an
33
+ # enumerable. If the enumerable is a numerical, time, or datetime range the test
34
+ # is performed with <tt>Range#cover?</tt>, otherwise with <tt>include?</tt>. When
35
+ # using a proc or lambda the instance under validation is passed as an argument.
36
+ # * <tt>:within</tt> - A synonym(or alias) for <tt>:in</tt>
37
+ # <tt>Range#cover?</tt>, otherwise with <tt>include?</tt>.
38
+ # * <tt>:message</tt> - Specifies a custom error message (default is: "is
39
+ # reserved").
40
+ #
41
+ # There is also a list of default options supported by every validator:
42
+ # +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+.
43
+ # See ActiveModel::Validations::ClassMethods#validates for more information.
44
+ def validates_exclusion_of(*attr_names)
45
+ validates_with ExclusionValidator, _merge_attributes(attr_names)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model/validations/resolve_value"
4
+
5
+ module ActiveModel
6
+ module Validations
7
+ class FormatValidator < EachValidator # :nodoc:
8
+ include ResolveValue
9
+
10
+ def validate_each(record, attribute, value)
11
+ if options[:with]
12
+ regexp = resolve_value(record, options[:with])
13
+ record_error(record, attribute, :with, value) unless regexp.match?(value.to_s)
14
+ elsif options[:without]
15
+ regexp = resolve_value(record, options[:without])
16
+ record_error(record, attribute, :without, value) if regexp.match?(value.to_s)
17
+ end
18
+ end
19
+
20
+ def check_validity!
21
+ unless options.include?(:with) ^ options.include?(:without) # ^ == xor, or "exclusive or"
22
+ raise ArgumentError, "Either :with or :without must be supplied (but not both)"
23
+ end
24
+
25
+ check_options_validity :with
26
+ check_options_validity :without
27
+ end
28
+
29
+ private
30
+ def record_error(record, attribute, name, value)
31
+ record.errors.add(attribute, :invalid, **options.except(name).merge!(value: value))
32
+ end
33
+
34
+ def check_options_validity(name)
35
+ if option = options[name]
36
+ if option.is_a?(Regexp)
37
+ if options[:multiline] != true && regexp_using_multiline_anchors?(option)
38
+ raise ArgumentError, "The provided regular expression is using multiline anchors (^ or $), " \
39
+ "which may present a security risk. Did you mean to use \\A and \\z, or forgot to add the " \
40
+ ":multiline => true option?"
41
+ end
42
+ elsif !option.respond_to?(:call)
43
+ raise ArgumentError, "A regular expression or a proc or lambda must be supplied as :#{name}"
44
+ end
45
+ end
46
+ end
47
+
48
+ def regexp_using_multiline_anchors?(regexp)
49
+ source = regexp.source
50
+ source.start_with?("^") || (source.end_with?("$") && !source.end_with?("\\$"))
51
+ end
52
+ end
53
+
54
+ module HelperMethods
55
+ # Validates whether the value of the specified attribute is of the correct
56
+ # form, going by the regular expression provided. You can require that the
57
+ # attribute matches the regular expression:
58
+ #
59
+ # class Person < ActiveRecord::Base
60
+ # validates_format_of :email, with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i, on: :create
61
+ # end
62
+ #
63
+ # Alternatively, you can require that the specified attribute does _not_
64
+ # match the regular expression:
65
+ #
66
+ # class Person < ActiveRecord::Base
67
+ # validates_format_of :email, without: /NOSPAM/
68
+ # end
69
+ #
70
+ # You can also provide a proc or lambda which will determine the regular
71
+ # expression that will be used to validate the attribute.
72
+ #
73
+ # class Person < ActiveRecord::Base
74
+ # # Admin can have number as a first letter in their screen name
75
+ # validates_format_of :screen_name,
76
+ # with: ->(person) { person.admin? ? /\A[a-z0-9][a-z0-9_\-]*\z/i : /\A[a-z][a-z0-9_\-]*\z/i }
77
+ # end
78
+ #
79
+ # Note: use <tt>\A</tt> and <tt>\z</tt> to match the start and end of the
80
+ # string, <tt>^</tt> and <tt>$</tt> match the start/end of a line.
81
+ #
82
+ # Due to frequent misuse of <tt>^</tt> and <tt>$</tt>, you need to pass
83
+ # the <tt>multiline: true</tt> option in case you use any of these two
84
+ # anchors in the provided regular expression. In most cases, you should be
85
+ # using <tt>\A</tt> and <tt>\z</tt>.
86
+ #
87
+ # You must pass either <tt>:with</tt> or <tt>:without</tt> as an option.
88
+ # In addition, both must be a regular expression or a proc or lambda, or
89
+ # else an exception will be raised.
90
+ #
91
+ # Configuration options:
92
+ # * <tt>:message</tt> - A custom error message (default is: "is invalid").
93
+ # * <tt>:with</tt> - Regular expression that if the attribute matches will
94
+ # result in a successful validation. This can be provided as a proc or
95
+ # lambda returning regular expression which will be called at runtime.
96
+ # * <tt>:without</tt> - Regular expression that if the attribute does not
97
+ # match will result in a successful validation. This can be provided as
98
+ # a proc or lambda returning regular expression which will be called at
99
+ # runtime.
100
+ # * <tt>:multiline</tt> - Set to true if your regular expression contains
101
+ # anchors that match the beginning or end of lines as opposed to the
102
+ # beginning or end of the string. These anchors are <tt>^</tt> and <tt>$</tt>.
103
+ #
104
+ # There is also a list of default options supported by every validator:
105
+ # +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+.
106
+ # See ActiveModel::Validations::ClassMethods#validates for more information.
107
+ def validates_format_of(*attr_names)
108
+ validates_with FormatValidator, _merge_attributes(attr_names)
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveModel
4
+ module Validations
5
+ module HelperMethods # :nodoc:
6
+ private
7
+ def _merge_attributes(attr_names)
8
+ options = attr_names.extract_options!.symbolize_keys
9
+ attr_names.flatten!
10
+ options[:attributes] = attr_names
11
+ options
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model/validations/clusivity"
4
+
5
+ module ActiveModel
6
+ module Validations
7
+ class InclusionValidator < EachValidator # :nodoc:
8
+ include Clusivity
9
+
10
+ def validate_each(record, attribute, value)
11
+ unless include?(record, value)
12
+ record.errors.add(attribute, :inclusion, **options.except(:in, :within).merge!(value: value))
13
+ end
14
+ end
15
+ end
16
+
17
+ module HelperMethods
18
+ # Validates whether the value of the specified attribute is available in a
19
+ # particular enumerable object.
20
+ #
21
+ # class Person < ActiveRecord::Base
22
+ # validates_inclusion_of :role, in: %w( admin contributor )
23
+ # validates_inclusion_of :age, in: 0..99
24
+ # validates_inclusion_of :format, in: %w( jpg gif png ), message: "extension %{value} is not included in the list"
25
+ # validates_inclusion_of :states, in: ->(person) { STATES[person.country] }
26
+ # validates_inclusion_of :karma, in: :available_karmas
27
+ # end
28
+ #
29
+ # Configuration options:
30
+ # * <tt>:in</tt> - An enumerable object of available items. This can be
31
+ # supplied as a proc, lambda, or symbol which returns an enumerable. If the
32
+ # enumerable is a numerical, time, or datetime range the test is performed
33
+ # with <tt>Range#cover?</tt>, otherwise with <tt>include?</tt>. When using
34
+ # a proc or lambda the instance under validation is passed as an argument.
35
+ # * <tt>:within</tt> - A synonym(or alias) for <tt>:in</tt>
36
+ # * <tt>:message</tt> - Specifies a custom error message (default is: "is
37
+ # not included in the list").
38
+ #
39
+ # There is also a list of default options supported by every validator:
40
+ # +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+.
41
+ # See ActiveModel::Validations::ClassMethods#validates for more information.
42
+ def validates_inclusion_of(*attr_names)
43
+ validates_with InclusionValidator, _merge_attributes(attr_names)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model/validations/resolve_value"
4
+
5
+ module ActiveModel
6
+ module Validations
7
+ class LengthValidator < EachValidator # :nodoc:
8
+ include ResolveValue
9
+
10
+ MESSAGES = { is: :wrong_length, minimum: :too_short, maximum: :too_long }.freeze
11
+ CHECKS = { is: :==, minimum: :>=, maximum: :<= }.freeze
12
+
13
+ RESERVED_OPTIONS = [:minimum, :maximum, :within, :is, :too_short, :too_long]
14
+
15
+ def initialize(options)
16
+ if range = (options.delete(:in) || options.delete(:within))
17
+ raise ArgumentError, ":in and :within must be a Range" unless range.is_a?(Range)
18
+ options[:minimum] = range.min if range.begin
19
+ options[:maximum] = (range.exclude_end? ? range.end - 1 : range.end) if range.end
20
+ end
21
+
22
+ if options[:allow_blank] == false && options[:minimum].nil? && options[:is].nil?
23
+ options[:minimum] = 1
24
+ end
25
+
26
+ super
27
+ end
28
+
29
+ def check_validity!
30
+ keys = CHECKS.keys & options.keys
31
+
32
+ if keys.empty?
33
+ raise ArgumentError, "Range unspecified. Specify the :in, :within, :maximum, :minimum, or :is option."
34
+ end
35
+
36
+ keys.each do |key|
37
+ value = options[key]
38
+
39
+ unless (value.is_a?(Integer) && value >= 0) ||
40
+ value == Float::INFINITY || value == -Float::INFINITY ||
41
+ value.is_a?(Symbol) || value.is_a?(Proc)
42
+ raise ArgumentError, ":#{key} must be a non-negative Integer, Infinity, Symbol, or Proc"
43
+ end
44
+ end
45
+ end
46
+
47
+ def validate_each(record, attribute, value)
48
+ value_length = value.respond_to?(:length) ? value.length : value.to_s.length
49
+ errors_options = options.except(*RESERVED_OPTIONS)
50
+
51
+ CHECKS.each do |key, validity_check|
52
+ next unless check_value = options[key]
53
+
54
+ if !value.nil? || skip_nil_check?(key)
55
+ check_value = resolve_value(record, check_value)
56
+ next if value_length.public_send(validity_check, check_value)
57
+ end
58
+
59
+ errors_options[:count] = check_value
60
+
61
+ default_message = options[MESSAGES[key]]
62
+ errors_options[:message] ||= default_message if default_message
63
+
64
+ record.errors.add(attribute, MESSAGES[key], **errors_options)
65
+ end
66
+ end
67
+
68
+ private
69
+ def skip_nil_check?(key)
70
+ key == :maximum && options[:allow_nil].nil? && options[:allow_blank].nil?
71
+ end
72
+ end
73
+
74
+ module HelperMethods
75
+ # Validates that the specified attributes match the length restrictions
76
+ # supplied. Only one constraint option can be used at a time apart from
77
+ # +:minimum+ and +:maximum+ that can be combined together:
78
+ #
79
+ # class Person < ActiveRecord::Base
80
+ # validates_length_of :first_name, maximum: 30
81
+ # validates_length_of :last_name, maximum: 30, message: "less than 30 if you don't mind"
82
+ # validates_length_of :fax, in: 7..32, allow_nil: true
83
+ # validates_length_of :phone, in: 7..32, allow_blank: true
84
+ # validates_length_of :user_name, within: 6..20, too_long: 'pick a shorter name', too_short: 'pick a longer name'
85
+ # validates_length_of :zip_code, minimum: 5, too_short: 'please enter at least 5 characters'
86
+ # validates_length_of :smurf_leader, is: 4, message: "papa is spelled with 4 characters... don't play me."
87
+ # validates_length_of :words_in_essay, minimum: 100, too_short: 'Your essay must be at least 100 words.'
88
+ #
89
+ # private
90
+ # def words_in_essay
91
+ # essay.scan(/\w+/)
92
+ # end
93
+ # end
94
+ #
95
+ # Constraint options:
96
+ #
97
+ # * <tt>:minimum</tt> - The minimum size of the attribute.
98
+ # * <tt>:maximum</tt> - The maximum size of the attribute. Allows +nil+ by
99
+ # default if not used with +:minimum+.
100
+ # * <tt>:is</tt> - The exact size of the attribute.
101
+ # * <tt>:within</tt> - A range specifying the minimum and maximum size of
102
+ # the attribute.
103
+ # * <tt>:in</tt> - A synonym (or alias) for <tt>:within</tt>.
104
+ #
105
+ # Other options:
106
+ #
107
+ # * <tt>:allow_nil</tt> - Attribute may be +nil+; skip validation.
108
+ # * <tt>:allow_blank</tt> - Attribute may be blank; skip validation.
109
+ # * <tt>:too_long</tt> - The error message if the attribute goes over the
110
+ # maximum (default is: "is too long (maximum is %{count} characters)").
111
+ # * <tt>:too_short</tt> - The error message if the attribute goes under the
112
+ # minimum (default is: "is too short (minimum is %{count} characters)").
113
+ # * <tt>:wrong_length</tt> - The error message if using the <tt>:is</tt>
114
+ # method and the attribute is the wrong size (default is: "is the wrong
115
+ # length (should be %{count} characters)").
116
+ # * <tt>:message</tt> - The error message to use for a <tt>:minimum</tt>,
117
+ # <tt>:maximum</tt>, or <tt>:is</tt> violation. An alias of the appropriate
118
+ # <tt>too_long</tt>/<tt>too_short</tt>/<tt>wrong_length</tt> message.
119
+ #
120
+ # There is also a list of default options supported by every validator:
121
+ # +:if+, +:unless+, +:on+, and +:strict+.
122
+ # See ActiveModel::Validations::ClassMethods#validates for more information.
123
+ def validates_length_of(*attr_names)
124
+ validates_with LengthValidator, _merge_attributes(attr_names)
125
+ end
126
+
127
+ alias_method :validates_size_of, :validates_length_of
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model/validations/comparability"
4
+ require "active_model/validations/resolve_value"
5
+ require "bigdecimal/util"
6
+
7
+ module ActiveModel
8
+ module Validations
9
+ class NumericalityValidator < EachValidator # :nodoc:
10
+ include Comparability
11
+ include ResolveValue
12
+
13
+ RANGE_CHECKS = { in: :in? }
14
+ NUMBER_CHECKS = { odd: :odd?, even: :even? }
15
+
16
+ RESERVED_OPTIONS = COMPARE_CHECKS.keys + NUMBER_CHECKS.keys + RANGE_CHECKS.keys + [:only_integer, :only_numeric]
17
+
18
+ INTEGER_REGEX = /\A[+-]?\d+\z/
19
+
20
+ HEXADECIMAL_REGEX = /\A[+-]?0[xX]/
21
+
22
+ def check_validity!
23
+ options.slice(*COMPARE_CHECKS.keys).each do |option, value|
24
+ unless value.is_a?(Numeric) || value.is_a?(Proc) || value.is_a?(Symbol)
25
+ raise ArgumentError, ":#{option} must be a number, a symbol or a proc"
26
+ end
27
+ end
28
+
29
+ options.slice(*RANGE_CHECKS.keys).each do |option, value|
30
+ unless value.is_a?(Range)
31
+ raise ArgumentError, ":#{option} must be a range"
32
+ end
33
+ end
34
+ end
35
+
36
+ def validate_each(record, attr_name, value, precision: Float::DIG, scale: nil)
37
+ unless is_number?(value, precision, scale)
38
+ record.errors.add(attr_name, :not_a_number, **filtered_options(value))
39
+ return
40
+ end
41
+
42
+ if allow_only_integer?(record) && !is_integer?(value)
43
+ record.errors.add(attr_name, :not_an_integer, **filtered_options(value))
44
+ return
45
+ end
46
+
47
+ value = parse_as_number(value, precision, scale)
48
+
49
+ options.slice(*RESERVED_OPTIONS).each do |option, option_value|
50
+ if NUMBER_CHECKS.include?(option)
51
+ unless value.to_i.public_send(NUMBER_CHECKS[option])
52
+ record.errors.add(attr_name, option, **filtered_options(value))
53
+ end
54
+ elsif RANGE_CHECKS.include?(option)
55
+ unless value.public_send(RANGE_CHECKS[option], option_value)
56
+ record.errors.add(attr_name, option, **filtered_options(value).merge!(count: option_value))
57
+ end
58
+ elsif COMPARE_CHECKS.include?(option)
59
+ option_value = option_as_number(record, option_value, precision, scale)
60
+ unless value.public_send(COMPARE_CHECKS[option], option_value)
61
+ record.errors.add(attr_name, option, **filtered_options(value).merge!(count: option_value))
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ private
68
+ def option_as_number(record, option_value, precision, scale)
69
+ parse_as_number(resolve_value(record, option_value), precision, scale)
70
+ end
71
+
72
+ def parse_as_number(raw_value, precision, scale)
73
+ if raw_value.is_a?(Float)
74
+ parse_float(raw_value, precision, scale)
75
+ elsif raw_value.is_a?(BigDecimal)
76
+ round(raw_value, scale)
77
+ elsif raw_value.is_a?(Numeric)
78
+ raw_value
79
+ elsif is_integer?(raw_value)
80
+ raw_value.to_i
81
+ elsif !is_hexadecimal_literal?(raw_value)
82
+ parse_float(Kernel.Float(raw_value), precision, scale)
83
+ end
84
+ end
85
+
86
+ def parse_float(raw_value, precision, scale)
87
+ round(raw_value, scale).to_d(precision)
88
+ end
89
+
90
+ def round(raw_value, scale)
91
+ scale ? raw_value.round(scale) : raw_value
92
+ end
93
+
94
+ def is_number?(raw_value, precision, scale)
95
+ if options[:only_numeric] && !raw_value.is_a?(Numeric)
96
+ return false
97
+ end
98
+
99
+ !parse_as_number(raw_value, precision, scale).nil?
100
+ rescue ArgumentError, TypeError
101
+ false
102
+ end
103
+
104
+ def is_integer?(raw_value)
105
+ INTEGER_REGEX.match?(raw_value.to_s)
106
+ end
107
+
108
+ def is_hexadecimal_literal?(raw_value)
109
+ HEXADECIMAL_REGEX.match?(raw_value.to_s)
110
+ end
111
+
112
+ def filtered_options(value)
113
+ filtered = options.except(*RESERVED_OPTIONS)
114
+ filtered[:value] = value
115
+ filtered
116
+ end
117
+
118
+ def allow_only_integer?(record)
119
+ resolve_value(record, options[:only_integer])
120
+ end
121
+
122
+ def prepare_value_for_validation(value, record, attr_name)
123
+ return value if record_attribute_changed_in_place?(record, attr_name)
124
+
125
+ came_from_user = :"#{attr_name}_came_from_user?"
126
+
127
+ if record.respond_to?(came_from_user)
128
+ if record.public_send(came_from_user)
129
+ raw_value = record.public_send(:"#{attr_name}_before_type_cast")
130
+ elsif record.respond_to?(:read_attribute)
131
+ raw_value = record.read_attribute(attr_name)
132
+ end
133
+ else
134
+ before_type_cast = :"#{attr_name}_before_type_cast"
135
+ if record.respond_to?(before_type_cast)
136
+ raw_value = record.public_send(before_type_cast)
137
+ end
138
+ end
139
+
140
+ raw_value || value
141
+ end
142
+
143
+ def record_attribute_changed_in_place?(record, attr_name)
144
+ record.respond_to?(:attribute_changed_in_place?) &&
145
+ record.attribute_changed_in_place?(attr_name.to_s)
146
+ end
147
+ end
148
+
149
+ module HelperMethods
150
+ # Validates whether the value of the specified attribute is numeric by
151
+ # trying to convert it to a float with +Kernel.Float+ (if
152
+ # <tt>only_integer</tt> is +false+) or applying it to the regular
153
+ # expression <tt>/\A[\+\-]?\d+\z/</tt> (if <tt>only_integer</tt> is set to
154
+ # +true+). Precision of +Kernel.Float+ values are guaranteed up to 15
155
+ # digits.
156
+ #
157
+ # class Person < ActiveRecord::Base
158
+ # validates_numericality_of :value, on: :create
159
+ # end
160
+ #
161
+ # Configuration options:
162
+ # * <tt>:message</tt> - A custom error message (default is: "is not a number").
163
+ # * <tt>:only_integer</tt> - Specifies whether the value has to be an
164
+ # integer (default is +false+).
165
+ # * <tt>:only_numeric</tt> - Specifies whether the value has to be an
166
+ # instance of Numeric (default is +false+). The default behavior is to
167
+ # attempt parsing the value if it is a String.
168
+ # * <tt>:allow_nil</tt> - Skip validation if attribute is +nil+ (default is
169
+ # +false+). Notice that for Integer and Float columns empty strings are
170
+ # converted to +nil+.
171
+ # * <tt>:greater_than</tt> - Specifies the value must be greater than the
172
+ # supplied value. The default error message for this option is _"must be
173
+ # greater than %{count}"_.
174
+ # * <tt>:greater_than_or_equal_to</tt> - Specifies the value must be
175
+ # greater than or equal the supplied value. The default error message
176
+ # for this option is _"must be greater than or equal to %{count}"_.
177
+ # * <tt>:equal_to</tt> - Specifies the value must be equal to the supplied
178
+ # value. The default error message for this option is _"must be equal to
179
+ # %{count}"_.
180
+ # * <tt>:less_than</tt> - Specifies the value must be less than the
181
+ # supplied value. The default error message for this option is _"must be
182
+ # less than %{count}"_.
183
+ # * <tt>:less_than_or_equal_to</tt> - Specifies the value must be less
184
+ # than or equal the supplied value. The default error message for this
185
+ # option is _"must be less than or equal to %{count}"_.
186
+ # * <tt>:other_than</tt> - Specifies the value must be other than the
187
+ # supplied value. The default error message for this option is _"must be
188
+ # other than %{count}"_.
189
+ # * <tt>:odd</tt> - Specifies the value must be an odd number. The default
190
+ # error message for this option is _"must be odd"_.
191
+ # * <tt>:even</tt> - Specifies the value must be an even number. The
192
+ # default error message for this option is _"must be even"_.
193
+ # * <tt>:in</tt> - Check that the value is within a range. The default
194
+ # error message for this option is _"must be in %{count}"_.
195
+ #
196
+ # There is also a list of default options supported by every validator:
197
+ # +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+ .
198
+ # See ActiveModel::Validations::ClassMethods#validates for more information.
199
+ #
200
+ # The following checks can also be supplied with a proc or a symbol which
201
+ # corresponds to a method:
202
+ #
203
+ # * <tt>:greater_than</tt>
204
+ # * <tt>:greater_than_or_equal_to</tt>
205
+ # * <tt>:equal_to</tt>
206
+ # * <tt>:less_than</tt>
207
+ # * <tt>:less_than_or_equal_to</tt>
208
+ # * <tt>:only_integer</tt>
209
+ # * <tt>:other_than</tt>
210
+ #
211
+ # For example:
212
+ #
213
+ # class Person < ActiveRecord::Base
214
+ # validates_numericality_of :width, less_than: ->(person) { person.height }
215
+ # validates_numericality_of :width, greater_than: :minimum_weight
216
+ # end
217
+ def validates_numericality_of(*attr_names)
218
+ validates_with NumericalityValidator, _merge_attributes(attr_names)
219
+ end
220
+ end
221
+ end
222
+ end