activemodel 5.2.6 → 6.1.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -109
  3. data/MIT-LICENSE +1 -1
  4. data/README.rdoc +6 -4
  5. data/lib/active_model.rb +2 -1
  6. data/lib/active_model/attribute.rb +21 -21
  7. data/lib/active_model/attribute/user_provided_default.rb +1 -2
  8. data/lib/active_model/attribute_assignment.rb +4 -6
  9. data/lib/active_model/attribute_methods.rb +117 -40
  10. data/lib/active_model/attribute_mutation_tracker.rb +90 -33
  11. data/lib/active_model/attribute_set.rb +20 -28
  12. data/lib/active_model/attribute_set/builder.rb +81 -16
  13. data/lib/active_model/attribute_set/yaml_encoder.rb +1 -2
  14. data/lib/active_model/attributes.rb +65 -44
  15. data/lib/active_model/callbacks.rb +11 -9
  16. data/lib/active_model/conversion.rb +1 -1
  17. data/lib/active_model/dirty.rb +51 -101
  18. data/lib/active_model/error.rb +207 -0
  19. data/lib/active_model/errors.rb +347 -155
  20. data/lib/active_model/gem_version.rb +3 -3
  21. data/lib/active_model/lint.rb +1 -1
  22. data/lib/active_model/naming.rb +22 -7
  23. data/lib/active_model/nested_error.rb +22 -0
  24. data/lib/active_model/railtie.rb +6 -0
  25. data/lib/active_model/secure_password.rb +54 -55
  26. data/lib/active_model/serialization.rb +9 -7
  27. data/lib/active_model/serializers/json.rb +17 -9
  28. data/lib/active_model/translation.rb +1 -1
  29. data/lib/active_model/type/big_integer.rb +0 -1
  30. data/lib/active_model/type/binary.rb +1 -1
  31. data/lib/active_model/type/boolean.rb +0 -1
  32. data/lib/active_model/type/date.rb +0 -5
  33. data/lib/active_model/type/date_time.rb +3 -8
  34. data/lib/active_model/type/decimal.rb +0 -1
  35. data/lib/active_model/type/float.rb +2 -3
  36. data/lib/active_model/type/helpers/accepts_multiparameter_time.rb +14 -6
  37. data/lib/active_model/type/helpers/numeric.rb +17 -6
  38. data/lib/active_model/type/helpers/time_value.rb +37 -15
  39. data/lib/active_model/type/helpers/timezone.rb +1 -1
  40. data/lib/active_model/type/immutable_string.rb +14 -11
  41. data/lib/active_model/type/integer.rb +15 -18
  42. data/lib/active_model/type/registry.rb +16 -16
  43. data/lib/active_model/type/string.rb +12 -3
  44. data/lib/active_model/type/time.rb +1 -6
  45. data/lib/active_model/type/value.rb +9 -2
  46. data/lib/active_model/validations.rb +6 -9
  47. data/lib/active_model/validations/absence.rb +2 -2
  48. data/lib/active_model/validations/acceptance.rb +34 -27
  49. data/lib/active_model/validations/callbacks.rb +15 -16
  50. data/lib/active_model/validations/clusivity.rb +6 -3
  51. data/lib/active_model/validations/confirmation.rb +4 -4
  52. data/lib/active_model/validations/exclusion.rb +1 -1
  53. data/lib/active_model/validations/format.rb +2 -3
  54. data/lib/active_model/validations/inclusion.rb +2 -2
  55. data/lib/active_model/validations/length.rb +3 -3
  56. data/lib/active_model/validations/numericality.rb +58 -44
  57. data/lib/active_model/validations/presence.rb +1 -1
  58. data/lib/active_model/validations/validates.rb +7 -6
  59. data/lib/active_model/validator.rb +8 -3
  60. metadata +14 -9
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/class/attribute"
4
+
5
+ module ActiveModel
6
+ # == Active \Model \Error
7
+ #
8
+ # Represents one single error
9
+ class Error
10
+ CALLBACKS_OPTIONS = [:if, :unless, :on, :allow_nil, :allow_blank, :strict]
11
+ MESSAGE_OPTIONS = [:message]
12
+
13
+ class_attribute :i18n_customize_full_message, default: false
14
+
15
+ def self.full_message(attribute, message, base) # :nodoc:
16
+ return message if attribute == :base
17
+
18
+ base_class = base.class
19
+ attribute = attribute.to_s
20
+
21
+ if i18n_customize_full_message && base_class.respond_to?(:i18n_scope)
22
+ attribute = attribute.remove(/\[\d+\]/)
23
+ parts = attribute.split(".")
24
+ attribute_name = parts.pop
25
+ namespace = parts.join("/") unless parts.empty?
26
+ attributes_scope = "#{base_class.i18n_scope}.errors.models"
27
+
28
+ if namespace
29
+ defaults = base_class.lookup_ancestors.map do |klass|
30
+ [
31
+ :"#{attributes_scope}.#{klass.model_name.i18n_key}/#{namespace}.attributes.#{attribute_name}.format",
32
+ :"#{attributes_scope}.#{klass.model_name.i18n_key}/#{namespace}.format",
33
+ ]
34
+ end
35
+ else
36
+ defaults = base_class.lookup_ancestors.map do |klass|
37
+ [
38
+ :"#{attributes_scope}.#{klass.model_name.i18n_key}.attributes.#{attribute_name}.format",
39
+ :"#{attributes_scope}.#{klass.model_name.i18n_key}.format",
40
+ ]
41
+ end
42
+ end
43
+
44
+ defaults.flatten!
45
+ else
46
+ defaults = []
47
+ end
48
+
49
+ defaults << :"errors.format"
50
+ defaults << "%{attribute} %{message}"
51
+
52
+ attr_name = attribute.tr(".", "_").humanize
53
+ attr_name = base_class.human_attribute_name(attribute, {
54
+ default: attr_name,
55
+ base: base,
56
+ })
57
+
58
+ I18n.t(defaults.shift,
59
+ default: defaults,
60
+ attribute: attr_name,
61
+ message: message)
62
+ end
63
+
64
+ def self.generate_message(attribute, type, base, options) # :nodoc:
65
+ type = options.delete(:message) if options[:message].is_a?(Symbol)
66
+ value = (attribute != :base ? base.read_attribute_for_validation(attribute) : nil)
67
+
68
+ options = {
69
+ model: base.model_name.human,
70
+ attribute: base.class.human_attribute_name(attribute, { base: base }),
71
+ value: value,
72
+ object: base
73
+ }.merge!(options)
74
+
75
+ if base.class.respond_to?(:i18n_scope)
76
+ i18n_scope = base.class.i18n_scope.to_s
77
+ attribute = attribute.to_s.remove(/\[\d+\]/)
78
+
79
+ defaults = base.class.lookup_ancestors.flat_map do |klass|
80
+ [ :"#{i18n_scope}.errors.models.#{klass.model_name.i18n_key}.attributes.#{attribute}.#{type}",
81
+ :"#{i18n_scope}.errors.models.#{klass.model_name.i18n_key}.#{type}" ]
82
+ end
83
+ defaults << :"#{i18n_scope}.errors.messages.#{type}"
84
+
85
+ catch(:exception) do
86
+ translation = I18n.translate(defaults.first, **options.merge(default: defaults.drop(1), throw: true))
87
+ return translation unless translation.nil?
88
+ end unless options[:message]
89
+ else
90
+ defaults = []
91
+ end
92
+
93
+ defaults << :"errors.attributes.#{attribute}.#{type}"
94
+ defaults << :"errors.messages.#{type}"
95
+
96
+ key = defaults.shift
97
+ defaults = options.delete(:message) if options[:message]
98
+ options[:default] = defaults
99
+
100
+ I18n.translate(key, **options)
101
+ end
102
+
103
+ def initialize(base, attribute, type = :invalid, **options)
104
+ @base = base
105
+ @attribute = attribute
106
+ @raw_type = type
107
+ @type = type || :invalid
108
+ @options = options
109
+ end
110
+
111
+ def initialize_dup(other) # :nodoc:
112
+ @attribute = @attribute.dup
113
+ @raw_type = @raw_type.dup
114
+ @type = @type.dup
115
+ @options = @options.deep_dup
116
+ end
117
+
118
+ # The object which the error belongs to
119
+ attr_reader :base
120
+ # The attribute of +base+ which the error belongs to
121
+ attr_reader :attribute
122
+ # The type of error, defaults to +:invalid+ unless specified
123
+ attr_reader :type
124
+ # The raw value provided as the second parameter when calling +errors#add+
125
+ attr_reader :raw_type
126
+ # The options provided when calling +errors#add+
127
+ attr_reader :options
128
+
129
+ # Returns the error message.
130
+ #
131
+ # error = ActiveModel::Error.new(person, :name, :too_short, count: 5)
132
+ # error.message
133
+ # # => "is too short (minimum is 5 characters)"
134
+ def message
135
+ case raw_type
136
+ when Symbol
137
+ self.class.generate_message(attribute, raw_type, @base, options.except(*CALLBACKS_OPTIONS))
138
+ else
139
+ raw_type
140
+ end
141
+ end
142
+
143
+ # Returns the error details.
144
+ #
145
+ # error = ActiveModel::Error.new(person, :name, :too_short, count: 5)
146
+ # error.details
147
+ # # => { error: :too_short, count: 5 }
148
+ def details
149
+ { error: raw_type }.merge(options.except(*CALLBACKS_OPTIONS + MESSAGE_OPTIONS))
150
+ end
151
+ alias_method :detail, :details
152
+
153
+ # Returns the full error message.
154
+ #
155
+ # error = ActiveModel::Error.new(person, :name, :too_short, count: 5)
156
+ # error.full_message
157
+ # # => "Name is too short (minimum is 5 characters)"
158
+ def full_message
159
+ self.class.full_message(attribute, message, @base)
160
+ end
161
+
162
+ # See if error matches provided +attribute+, +type+ and +options+.
163
+ #
164
+ # Omitted params are not checked for a match.
165
+ def match?(attribute, type = nil, **options)
166
+ if @attribute != attribute || (type && @type != type)
167
+ return false
168
+ end
169
+
170
+ options.each do |key, value|
171
+ if @options[key] != value
172
+ return false
173
+ end
174
+ end
175
+
176
+ true
177
+ end
178
+
179
+ # See if error matches provided +attribute+, +type+ and +options+ exactly.
180
+ #
181
+ # All params must be equal to Error's own attributes to be considered a
182
+ # strict match.
183
+ def strict_match?(attribute, type, **options)
184
+ return false unless match?(attribute, type)
185
+
186
+ options == @options.except(*CALLBACKS_OPTIONS + MESSAGE_OPTIONS)
187
+ end
188
+
189
+ def ==(other) # :nodoc:
190
+ other.is_a?(self.class) && attributes_for_hash == other.attributes_for_hash
191
+ end
192
+ alias eql? ==
193
+
194
+ def hash # :nodoc:
195
+ attributes_for_hash.hash
196
+ end
197
+
198
+ def inspect # :nodoc:
199
+ "#<#{self.class.name} attribute=#{@attribute}, type=#{@type}, options=#{@options.inspect}>"
200
+ end
201
+
202
+ protected
203
+ def attributes_for_hash
204
+ [@base, @attribute, @raw_type, @options.except(*CALLBACKS_OPTIONS)]
205
+ end
206
+ end
207
+ end
@@ -4,11 +4,14 @@ require "active_support/core_ext/array/conversions"
4
4
  require "active_support/core_ext/string/inflections"
5
5
  require "active_support/core_ext/object/deep_dup"
6
6
  require "active_support/core_ext/string/filters"
7
+ require "active_model/error"
8
+ require "active_model/nested_error"
9
+ require "forwardable"
7
10
 
8
11
  module ActiveModel
9
12
  # == Active \Model \Errors
10
13
  #
11
- # Provides a modified +Hash+ that you can include in your object
14
+ # Provides error related functionalities you can include in your object
12
15
  # for handling error messages and interacting with Action View helpers.
13
16
  #
14
17
  # A minimal implementation could be:
@@ -59,10 +62,18 @@ module ActiveModel
59
62
  class Errors
60
63
  include Enumerable
61
64
 
62
- CALLBACKS_OPTIONS = [:if, :unless, :on, :allow_nil, :allow_blank, :strict]
63
- MESSAGE_OPTIONS = [:message]
65
+ extend Forwardable
66
+ def_delegators :@errors, :size, :clear, :blank?, :empty?, :uniq!, :any?
67
+ # TODO: forward all enumerable methods after `each` deprecation is removed.
68
+ def_delegators :@errors, :count
64
69
 
65
- attr_reader :messages, :details
70
+ LEGACY_ATTRIBUTES = [:messages, :details].freeze
71
+ private_constant :LEGACY_ATTRIBUTES
72
+
73
+ # The actual array of +Error+ objects
74
+ # This method is aliased to <tt>objects</tt>.
75
+ attr_reader :errors
76
+ alias :objects :errors
66
77
 
67
78
  # Pass in the instance of the object that is using the errors object.
68
79
  #
@@ -72,18 +83,17 @@ module ActiveModel
72
83
  # end
73
84
  # end
74
85
  def initialize(base)
75
- @base = base
76
- @messages = apply_default_array({})
77
- @details = apply_default_array({})
86
+ @base = base
87
+ @errors = []
78
88
  end
79
89
 
80
90
  def initialize_dup(other) # :nodoc:
81
- @messages = other.messages.dup
82
- @details = other.details.deep_dup
91
+ @errors = other.errors.deep_dup
83
92
  super
84
93
  end
85
94
 
86
95
  # Copies the errors from <tt>other</tt>.
96
+ # For copying errors but keep <tt>@base</tt> as is.
87
97
  #
88
98
  # other - The ActiveModel::Errors instance.
89
99
  #
@@ -91,11 +101,31 @@ module ActiveModel
91
101
  #
92
102
  # person.errors.copy!(other)
93
103
  def copy!(other) # :nodoc:
94
- @messages = other.messages.dup
95
- @details = other.details.dup
104
+ @errors = other.errors.deep_dup
105
+ @errors.each { |error|
106
+ error.instance_variable_set(:@base, @base)
107
+ }
108
+ end
109
+
110
+ # Imports one error
111
+ # Imported errors are wrapped as a NestedError,
112
+ # providing access to original error object.
113
+ # If attribute or type needs to be overridden, use +override_options+.
114
+ #
115
+ # override_options - Hash
116
+ # @option override_options [Symbol] :attribute Override the attribute the error belongs to
117
+ # @option override_options [Symbol] :type Override type of the error.
118
+ def import(error, override_options = {})
119
+ [:attribute, :type].each do |key|
120
+ if override_options.key?(key)
121
+ override_options[key] = override_options[key].to_sym
122
+ end
123
+ end
124
+ @errors.append(NestedError.new(@base, error, override_options))
96
125
  end
97
126
 
98
- # Merges the errors from <tt>other</tt>.
127
+ # Merges the errors from <tt>other</tt>,
128
+ # each <tt>Error</tt> wrapped as <tt>NestedError</tt>.
99
129
  #
100
130
  # other - The ActiveModel::Errors instance.
101
131
  #
@@ -103,18 +133,42 @@ module ActiveModel
103
133
  #
104
134
  # person.errors.merge!(other)
105
135
  def merge!(other)
106
- @messages.merge!(other.messages) { |_, ary1, ary2| ary1 + ary2 }
107
- @details.merge!(other.details) { |_, ary1, ary2| ary1 + ary2 }
136
+ other.errors.each { |error|
137
+ import(error)
138
+ }
108
139
  end
109
140
 
110
- # Clear the error messages.
141
+ # Removes all errors except the given keys. Returns a hash containing the removed errors.
111
142
  #
112
- # person.errors.full_messages # => ["name cannot be nil"]
113
- # person.errors.clear
114
- # person.errors.full_messages # => []
115
- def clear
116
- messages.clear
117
- details.clear
143
+ # person.errors.keys # => [:name, :age, :gender, :city]
144
+ # person.errors.slice!(:age, :gender) # => { :name=>["cannot be nil"], :city=>["cannot be nil"] }
145
+ # person.errors.keys # => [:age, :gender]
146
+ def slice!(*keys)
147
+ deprecation_removal_warning(:slice!)
148
+
149
+ keys = keys.map(&:to_sym)
150
+
151
+ results = messages.dup.slice!(*keys)
152
+
153
+ @errors.keep_if do |error|
154
+ keys.include?(error.attribute)
155
+ end
156
+
157
+ results
158
+ end
159
+
160
+ # Search for errors matching +attribute+, +type+ or +options+.
161
+ #
162
+ # Only supplied params will be matched.
163
+ #
164
+ # person.errors.where(:name) # => all name errors.
165
+ # person.errors.where(:name, :too_short) # => all name errors being too short
166
+ # person.errors.where(:name, :too_short, minimum: 2) # => all name errors being too short and minimum is 2
167
+ def where(attribute, type = nil, **options)
168
+ attribute, type, options = normalize_arguments(attribute, type, **options)
169
+ @errors.select { |error|
170
+ error.match?(attribute, type, **options)
171
+ }
118
172
  end
119
173
 
120
174
  # Returns +true+ if the error messages include an error for the given key
@@ -124,8 +178,9 @@ module ActiveModel
124
178
  # person.errors.include?(:name) # => true
125
179
  # person.errors.include?(:age) # => false
126
180
  def include?(attribute)
127
- attribute = attribute.to_sym
128
- messages.key?(attribute) && messages[attribute].present?
181
+ @errors.any? { |error|
182
+ error.match?(attribute.to_sym)
183
+ }
129
184
  end
130
185
  alias :has_key? :include?
131
186
  alias :key? :include?
@@ -135,10 +190,13 @@ module ActiveModel
135
190
  # person.errors[:name] # => ["cannot be nil"]
136
191
  # person.errors.delete(:name) # => ["cannot be nil"]
137
192
  # person.errors[:name] # => []
138
- def delete(key)
139
- attribute = key.to_sym
140
- details.delete(attribute)
141
- messages.delete(attribute)
193
+ def delete(attribute, type = nil, **options)
194
+ attribute, type, options = normalize_arguments(attribute, type, **options)
195
+ matches = where(attribute, type, **options)
196
+ matches.each do |error|
197
+ @errors.delete(error)
198
+ end
199
+ matches.map(&:message).presence
142
200
  end
143
201
 
144
202
  # When passed a symbol or a name of a method, returns an array of errors
@@ -147,48 +205,65 @@ module ActiveModel
147
205
  # person.errors[:name] # => ["cannot be nil"]
148
206
  # person.errors['name'] # => ["cannot be nil"]
149
207
  def [](attribute)
150
- messages[attribute.to_sym]
208
+ DeprecationHandlingMessageArray.new(messages_for(attribute), self, attribute)
151
209
  end
152
210
 
153
- # Iterates through each error key, value pair in the error messages hash.
211
+ # Iterates through each error object.
212
+ #
213
+ # person.errors.add(:name, :too_short, count: 2)
214
+ # person.errors.each do |error|
215
+ # # Will yield <#ActiveModel::Error attribute=name, type=too_short,
216
+ # options={:count=>3}>
217
+ # end
218
+ #
219
+ # To be backward compatible with past deprecated hash-like behavior,
220
+ # when block accepts two parameters instead of one, it
221
+ # iterates through each error key, value pair in the error messages hash.
154
222
  # Yields the attribute and the error for that attribute. If the attribute
155
223
  # has more than one error message, yields once for each error message.
156
224
  #
157
225
  # person.errors.add(:name, :blank, message: "can't be blank")
158
- # person.errors.each do |attribute, error|
226
+ # person.errors.each do |attribute, message|
159
227
  # # Will yield :name and "can't be blank"
160
228
  # end
161
229
  #
162
230
  # person.errors.add(:name, :not_specified, message: "must be specified")
163
- # person.errors.each do |attribute, error|
231
+ # person.errors.each do |attribute, message|
164
232
  # # Will yield :name and "can't be blank"
165
233
  # # then yield :name and "must be specified"
166
234
  # end
167
- def each
168
- messages.each_key do |attribute|
169
- messages[attribute].each { |error| yield attribute, error }
235
+ def each(&block)
236
+ if block.arity <= 1
237
+ @errors.each(&block)
238
+ else
239
+ ActiveSupport::Deprecation.warn(<<~MSG)
240
+ Enumerating ActiveModel::Errors as a hash has been deprecated.
241
+ In Rails 6.1, `errors` is an array of Error objects,
242
+ therefore it should be accessed by a block with a single block
243
+ parameter like this:
244
+
245
+ person.errors.each do |error|
246
+ attribute = error.attribute
247
+ message = error.message
248
+ end
249
+
250
+ You are passing a block expecting two parameters,
251
+ so the old hash behavior is simulated. As this is deprecated,
252
+ this will result in an ArgumentError in Rails 6.2.
253
+ MSG
254
+ @errors.
255
+ sort { |a, b| a.attribute <=> b.attribute }.
256
+ each { |error| yield error.attribute, error.message }
170
257
  end
171
258
  end
172
259
 
173
- # Returns the number of error messages.
174
- #
175
- # person.errors.add(:name, :blank, message: "can't be blank")
176
- # person.errors.size # => 1
177
- # person.errors.add(:name, :not_specified, message: "must be specified")
178
- # person.errors.size # => 2
179
- def size
180
- values.flatten.size
181
- end
182
- alias :count :size
183
-
184
260
  # Returns all message values.
185
261
  #
186
262
  # person.errors.messages # => {:name=>["cannot be nil", "must be specified"]}
187
263
  # person.errors.values # => [["cannot be nil", "must be specified"]]
188
264
  def values
189
- messages.select do |key, value|
190
- !value.empty?
191
- end.values
265
+ deprecation_removal_warning(:values, "errors.map { |error| error.message }")
266
+ @errors.map(&:message).freeze
192
267
  end
193
268
 
194
269
  # Returns all message keys.
@@ -196,20 +271,19 @@ module ActiveModel
196
271
  # person.errors.messages # => {:name=>["cannot be nil", "must be specified"]}
197
272
  # person.errors.keys # => [:name]
198
273
  def keys
199
- messages.select do |key, value|
200
- !value.empty?
201
- end.keys
274
+ deprecation_removal_warning(:keys, "errors.attribute_names")
275
+ keys = @errors.map(&:attribute)
276
+ keys.uniq!
277
+ keys.freeze
202
278
  end
203
279
 
204
- # Returns +true+ if no errors are found, +false+ otherwise.
205
- # If the error message is a string it can be empty.
280
+ # Returns all error attribute names
206
281
  #
207
- # person.errors.full_messages # => ["name cannot be nil"]
208
- # person.errors.empty? # => false
209
- def empty?
210
- size.zero?
282
+ # person.errors.messages # => {:name=>["cannot be nil", "must be specified"]}
283
+ # person.errors.attribute_names # => [:name]
284
+ def attribute_names
285
+ @errors.map(&:attribute).uniq.freeze
211
286
  end
212
- alias :blank? :empty?
213
287
 
214
288
  # Returns an xml formatted representation of the Errors hash.
215
289
  #
@@ -223,6 +297,7 @@ module ActiveModel
223
297
  # # <error>name must be specified</error>
224
298
  # # </errors>
225
299
  def to_xml(options = {})
300
+ deprecation_removal_warning(:to_xml)
226
301
  to_a.to_xml({ root: "errors", skip_types: true }.merge!(options))
227
302
  end
228
303
 
@@ -242,34 +317,68 @@ module ActiveModel
242
317
  # person.errors.to_hash # => {:name=>["cannot be nil"]}
243
318
  # person.errors.to_hash(true) # => {:name=>["name cannot be nil"]}
244
319
  def to_hash(full_messages = false)
245
- if full_messages
246
- messages.each_with_object({}) do |(attribute, array), messages|
247
- messages[attribute] = array.map { |message| full_message(attribute, message) }
248
- end
249
- else
250
- without_default_proc(messages)
320
+ message_method = full_messages ? :full_message : :message
321
+ group_by_attribute.transform_values do |errors|
322
+ errors.map(&message_method)
323
+ end
324
+ end
325
+
326
+ def to_h
327
+ ActiveSupport::Deprecation.warn(<<~EOM)
328
+ ActiveModel::Errors#to_h is deprecated and will be removed in Rails 6.2.
329
+ Please use `ActiveModel::Errors.to_hash` instead. The values in the hash
330
+ returned by `ActiveModel::Errors.to_hash` is an array of error messages.
331
+ EOM
332
+
333
+ to_hash.transform_values { |values| values.last }
334
+ end
335
+
336
+ # Returns a Hash of attributes with an array of their error messages.
337
+ #
338
+ # Updating this hash would still update errors state for backward
339
+ # compatibility, but this behavior is deprecated.
340
+ def messages
341
+ DeprecationHandlingMessageHash.new(self)
342
+ end
343
+
344
+ # Returns a Hash of attributes with an array of their error details.
345
+ #
346
+ # Updating this hash would still update errors state for backward
347
+ # compatibility, but this behavior is deprecated.
348
+ def details
349
+ hash = group_by_attribute.transform_values do |errors|
350
+ errors.map(&:details)
251
351
  end
352
+ DeprecationHandlingDetailsHash.new(hash)
353
+ end
354
+
355
+ # Returns a Hash of attributes with an array of their Error objects.
356
+ #
357
+ # person.errors.group_by_attribute
358
+ # # => {:name=>[<#ActiveModel::Error>, <#ActiveModel::Error>]}
359
+ def group_by_attribute
360
+ @errors.group_by(&:attribute)
252
361
  end
253
362
 
254
- # Adds +message+ to the error messages and used validator type to +details+ on +attribute+.
363
+ # Adds a new error of +type+ on +attribute+.
255
364
  # More than one error can be added to the same +attribute+.
256
- # If no +message+ is supplied, <tt>:invalid</tt> is assumed.
365
+ # If no +type+ is supplied, <tt>:invalid</tt> is assumed.
257
366
  #
258
367
  # person.errors.add(:name)
259
- # # => ["is invalid"]
368
+ # # Adds <#ActiveModel::Error attribute=name, type=invalid>
260
369
  # person.errors.add(:name, :not_implemented, message: "must be implemented")
261
- # # => ["is invalid", "must be implemented"]
370
+ # # Adds <#ActiveModel::Error attribute=name, type=not_implemented,
371
+ # options={:message=>"must be implemented"}>
262
372
  #
263
373
  # person.errors.messages
264
374
  # # => {:name=>["is invalid", "must be implemented"]}
265
375
  #
266
- # person.errors.details
267
- # # => {:name=>[{error: :not_implemented}, {error: :invalid}]}
376
+ # If +type+ is a string, it will be used as error message.
268
377
  #
269
- # If +message+ is a symbol, it will be translated using the appropriate
378
+ # If +type+ is a symbol, it will be translated using the appropriate
270
379
  # scope (see +generate_message+).
271
380
  #
272
- # If +message+ is a proc, it will be called, allowing for things like
381
+ # If +type+ is a proc, it will be called, allowing for things like
273
382
  # <tt>Time.now</tt> to be used within an error.
274
383
  #
275
384
  # If the <tt>:strict</tt> option is set to +true+, it will raise
@@ -292,42 +401,66 @@ module ActiveModel
292
401
  # # => {:base=>["either name or email must be present"]}
293
402
  # person.errors.details
294
403
  # # => {:base=>[{error: :name_or_email_blank}]}
295
- def add(attribute, message = :invalid, options = {})
296
- message = message.call if message.respond_to?(:call)
297
- detail = normalize_detail(message, options)
298
- message = normalize_message(attribute, message, options)
404
+ def add(attribute, type = :invalid, **options)
405
+ attribute, type, options = normalize_arguments(attribute, type, **options)
406
+ error = Error.new(@base, attribute, type, **options)
407
+
299
408
  if exception = options[:strict]
300
409
  exception = ActiveModel::StrictValidationFailed if exception == true
301
- raise exception, full_message(attribute, message)
410
+ raise exception, error.full_message
302
411
  end
303
412
 
304
- details[attribute.to_sym] << detail
305
- messages[attribute.to_sym] << message
413
+ @errors.append(error)
414
+
415
+ error
306
416
  end
307
417
 
308
- # Returns +true+ if an error on the attribute with the given message is
309
- # present, or +false+ otherwise. +message+ is treated the same as for +add+.
418
+ # Returns +true+ if an error matches provided +attribute+ and +type+,
419
+ # or +false+ otherwise. +type+ is treated the same as for +add+.
310
420
  #
311
421
  # person.errors.add :name, :blank
312
422
  # person.errors.added? :name, :blank # => true
313
423
  # person.errors.added? :name, "can't be blank" # => true
314
424
  #
315
- # If the error message requires an option, then it returns +true+ with
316
- # the correct option, or +false+ with an incorrect or missing option.
317
- #
318
- # person.errors.add :name, :too_long, { count: 25 }
319
- # person.errors.added? :name, :too_long, count: 25 # => true
320
- # person.errors.added? :name, "is too long (maximum is 25 characters)" # => true
321
- # person.errors.added? :name, :too_long, count: 24 # => false
322
- # person.errors.added? :name, :too_long # => false
323
- # person.errors.added? :name, "is too long" # => false
324
- def added?(attribute, message = :invalid, options = {})
325
- message = message.call if message.respond_to?(:call)
425
+ # If the error requires options, then it returns +true+ with
426
+ # the correct options, or +false+ with incorrect or missing options.
427
+ #
428
+ # person.errors.add :name, :too_long, { count: 25 }
429
+ # person.errors.added? :name, :too_long, count: 25 # => true
430
+ # person.errors.added? :name, "is too long (maximum is 25 characters)" # => true
431
+ # person.errors.added? :name, :too_long, count: 24 # => false
432
+ # person.errors.added? :name, :too_long # => false
433
+ # person.errors.added? :name, "is too long" # => false
434
+ def added?(attribute, type = :invalid, options = {})
435
+ attribute, type, options = normalize_arguments(attribute, type, **options)
436
+
437
+ if type.is_a? Symbol
438
+ @errors.any? { |error|
439
+ error.strict_match?(attribute, type, **options)
440
+ }
441
+ else
442
+ messages_for(attribute).include?(type)
443
+ end
444
+ end
326
445
 
327
- if message.is_a? Symbol
328
- details[attribute.to_sym].include? normalize_detail(message, options)
446
+ # Returns +true+ if an error on the attribute with the given type is
447
+ # present, or +false+ otherwise. +type+ is treated the same as for +add+.
448
+ #
449
+ # person.errors.add :age
450
+ # person.errors.add :name, :too_long, { count: 25 }
451
+ # person.errors.of_kind? :age # => true
452
+ # person.errors.of_kind? :name # => false
453
+ # person.errors.of_kind? :name, :too_long # => true
454
+ # person.errors.of_kind? :name, "is too long (maximum is 25 characters)" # => true
455
+ # person.errors.of_kind? :name, :not_too_long # => false
456
+ # person.errors.of_kind? :name, "is too long" # => false
457
+ def of_kind?(attribute, type = :invalid)
458
+ attribute, type = normalize_arguments(attribute, type)
459
+
460
+ if type.is_a? Symbol
461
+ !where(attribute, type).empty?
329
462
  else
330
- self[attribute].include? message
463
+ messages_for(attribute).include?(type)
331
464
  end
332
465
  end
333
466
 
@@ -342,7 +475,7 @@ module ActiveModel
342
475
  # person.errors.full_messages
343
476
  # # => ["Name is too short (minimum is 5 characters)", "Name can't be blank", "Email can't be blank"]
344
477
  def full_messages
345
- map { |attribute, message| full_message(attribute, message) }
478
+ @errors.map(&:full_message)
346
479
  end
347
480
  alias :to_a :full_messages
348
481
 
@@ -357,21 +490,28 @@ module ActiveModel
357
490
  # person.errors.full_messages_for(:name)
358
491
  # # => ["Name is too short (minimum is 5 characters)", "Name can't be blank"]
359
492
  def full_messages_for(attribute)
360
- attribute = attribute.to_sym
361
- messages[attribute].map { |message| full_message(attribute, message) }
493
+ where(attribute).map(&:full_message).freeze
494
+ end
495
+
496
+ # Returns all the error messages for a given attribute in an array.
497
+ #
498
+ # class Person
499
+ # validates_presence_of :name, :email
500
+ # validates_length_of :name, in: 5..30
501
+ # end
502
+ #
503
+ # person = Person.create()
504
+ # person.errors.messages_for(:name)
505
+ # # => ["is too short (minimum is 5 characters)", "can't be blank"]
506
+ def messages_for(attribute)
507
+ where(attribute).map(&:message)
362
508
  end
363
509
 
364
510
  # Returns a full message for a given attribute.
365
511
  #
366
512
  # person.errors.full_message(:name, 'is invalid') # => "Name is invalid"
367
513
  def full_message(attribute, message)
368
- return message if attribute == :base
369
- attr_name = attribute.to_s.tr(".", "_").humanize
370
- attr_name = @base.class.human_attribute_name(attribute, default: attr_name)
371
- I18n.t(:"errors.format",
372
- default: "%{attribute} %{message}",
373
- attribute: attr_name,
374
- message: message)
514
+ Error.full_message(attribute, message, @base)
375
515
  end
376
516
 
377
517
  # Translates an error message in its default scope
@@ -399,77 +539,129 @@ module ActiveModel
399
539
  # * <tt>errors.attributes.title.blank</tt>
400
540
  # * <tt>errors.messages.blank</tt>
401
541
  def generate_message(attribute, type = :invalid, options = {})
402
- type = options.delete(:message) if options[:message].is_a?(Symbol)
542
+ Error.generate_message(attribute, type, @base, options)
543
+ end
403
544
 
404
- if @base.class.respond_to?(:i18n_scope)
405
- i18n_scope = @base.class.i18n_scope.to_s
406
- defaults = @base.class.lookup_ancestors.flat_map do |klass|
407
- [ :"#{i18n_scope}.errors.models.#{klass.model_name.i18n_key}.attributes.#{attribute}.#{type}",
408
- :"#{i18n_scope}.errors.models.#{klass.model_name.i18n_key}.#{type}" ]
545
+ def marshal_load(array) # :nodoc:
546
+ # Rails 5
547
+ @errors = []
548
+ @base = array[0]
549
+ add_from_legacy_details_hash(array[2])
550
+ end
551
+
552
+ def init_with(coder) # :nodoc:
553
+ data = coder.map
554
+
555
+ data.each { |k, v|
556
+ next if LEGACY_ATTRIBUTES.include?(k.to_sym)
557
+ instance_variable_set(:"@#{k}", v)
558
+ }
559
+
560
+ @errors ||= []
561
+
562
+ # Legacy support Rails 5.x details hash
563
+ add_from_legacy_details_hash(data["details"]) if data.key?("details")
564
+ end
565
+
566
+ private
567
+ def normalize_arguments(attribute, type, **options)
568
+ # Evaluate proc first
569
+ if type.respond_to?(:call)
570
+ type = type.call(@base, options)
409
571
  end
410
- defaults << :"#{i18n_scope}.errors.messages.#{type}"
411
- else
412
- defaults = []
572
+
573
+ [attribute.to_sym, type, options]
413
574
  end
414
575
 
415
- defaults << :"errors.attributes.#{attribute}.#{type}"
416
- defaults << :"errors.messages.#{type}"
576
+ def add_from_legacy_details_hash(details)
577
+ details.each { |attribute, errors|
578
+ errors.each { |error|
579
+ type = error.delete(:error)
580
+ add(attribute, type, **error)
581
+ }
582
+ }
583
+ end
417
584
 
418
- key = defaults.shift
419
- defaults = options.delete(:message) if options[:message]
420
- value = (attribute != :base ? @base.send(:read_attribute_for_validation, attribute) : nil)
585
+ def deprecation_removal_warning(method_name, alternative_message = nil)
586
+ message = +"ActiveModel::Errors##{method_name} is deprecated and will be removed in Rails 6.2."
587
+ if alternative_message
588
+ message << "\n\nTo achieve the same use:\n\n "
589
+ message << alternative_message
590
+ end
591
+ ActiveSupport::Deprecation.warn(message)
592
+ end
421
593
 
422
- options = {
423
- default: defaults,
424
- model: @base.model_name.human,
425
- attribute: @base.class.human_attribute_name(attribute),
426
- value: value,
427
- object: @base
428
- }.merge!(options)
594
+ def deprecation_rename_warning(old_method_name, new_method_name)
595
+ ActiveSupport::Deprecation.warn("ActiveModel::Errors##{old_method_name} is deprecated. Please call ##{new_method_name} instead.")
596
+ end
597
+ end
429
598
 
430
- I18n.translate(key, options)
599
+ class DeprecationHandlingMessageHash < SimpleDelegator
600
+ def initialize(errors)
601
+ @errors = errors
602
+ super(prepare_content)
431
603
  end
432
604
 
433
- def marshal_dump # :nodoc:
434
- [@base, without_default_proc(@messages), without_default_proc(@details)]
435
- end
605
+ def []=(attribute, value)
606
+ ActiveSupport::Deprecation.warn("Calling `[]=` to an ActiveModel::Errors is deprecated. Please call `ActiveModel::Errors#add` instead.")
436
607
 
437
- def marshal_load(array) # :nodoc:
438
- @base, @messages, @details = array
439
- apply_default_array(@messages)
440
- apply_default_array(@details)
608
+ @errors.delete(attribute)
609
+ Array(value).each do |message|
610
+ @errors.add(attribute, message)
611
+ end
612
+
613
+ __setobj__ prepare_content
441
614
  end
442
615
 
443
- def init_with(coder) # :nodoc:
444
- coder.map.each { |k, v| instance_variable_set(:"@#{k}", v) }
445
- @details ||= {}
446
- apply_default_array(@messages)
447
- apply_default_array(@details)
616
+ def delete(attribute)
617
+ ActiveSupport::Deprecation.warn("Calling `delete` to an ActiveModel::Errors messages hash is deprecated. Please call `ActiveModel::Errors#delete` instead.")
618
+
619
+ @errors.delete(attribute)
448
620
  end
449
621
 
450
- private
451
- def normalize_message(attribute, message, options)
452
- case message
453
- when Symbol
454
- generate_message(attribute, message, options.except(*CALLBACKS_OPTIONS))
455
- else
456
- message
622
+ private
623
+ def prepare_content
624
+ content = @errors.to_hash
625
+ content.each do |attribute, value|
626
+ content[attribute] = DeprecationHandlingMessageArray.new(value, @errors, attribute)
627
+ end
628
+ content.default_proc = proc do |hash, attribute|
629
+ hash = hash.dup
630
+ hash[attribute] = DeprecationHandlingMessageArray.new([], @errors, attribute)
631
+ __setobj__ hash.freeze
632
+ hash[attribute]
633
+ end
634
+ content.freeze
457
635
  end
636
+ end
637
+
638
+ class DeprecationHandlingMessageArray < SimpleDelegator
639
+ def initialize(content, errors, attribute)
640
+ @errors = errors
641
+ @attribute = attribute
642
+ super(content.freeze)
458
643
  end
459
644
 
460
- def normalize_detail(message, options)
461
- { error: message }.merge(options.except(*CALLBACKS_OPTIONS + MESSAGE_OPTIONS))
645
+ def <<(message)
646
+ ActiveSupport::Deprecation.warn("Calling `<<` to an ActiveModel::Errors message array in order to add an error is deprecated. Please call `ActiveModel::Errors#add` instead.")
647
+
648
+ @errors.add(@attribute, message)
649
+ __setobj__ @errors.messages_for(@attribute)
650
+ self
462
651
  end
463
652
 
464
- def without_default_proc(hash)
465
- hash.dup.tap do |new_h|
466
- new_h.default_proc = nil
467
- end
653
+ def clear
654
+ ActiveSupport::Deprecation.warn("Calling `clear` to an ActiveModel::Errors message array in order to delete all errors is deprecated. Please call `ActiveModel::Errors#delete` instead.")
655
+
656
+ @errors.delete(@attribute)
468
657
  end
658
+ end
469
659
 
470
- def apply_default_array(hash)
471
- hash.default_proc = proc { |h, key| h[key] = [] }
472
- hash
660
+ class DeprecationHandlingDetailsHash < SimpleDelegator
661
+ def initialize(details)
662
+ details.default = []
663
+ details.freeze
664
+ super(details)
473
665
  end
474
666
  end
475
667