activemodel 6.0.3.2 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -182
  3. data/MIT-LICENSE +1 -1
  4. data/README.rdoc +1 -1
  5. data/lib/active_model.rb +2 -1
  6. data/lib/active_model/attribute.rb +15 -14
  7. data/lib/active_model/attribute_assignment.rb +3 -4
  8. data/lib/active_model/attribute_methods.rb +74 -38
  9. data/lib/active_model/attribute_mutation_tracker.rb +8 -5
  10. data/lib/active_model/attribute_set.rb +18 -16
  11. data/lib/active_model/attribute_set/builder.rb +80 -13
  12. data/lib/active_model/attributes.rb +20 -24
  13. data/lib/active_model/dirty.rb +12 -4
  14. data/lib/active_model/error.rb +207 -0
  15. data/lib/active_model/errors.rb +316 -208
  16. data/lib/active_model/gem_version.rb +3 -3
  17. data/lib/active_model/lint.rb +1 -1
  18. data/lib/active_model/naming.rb +2 -2
  19. data/lib/active_model/nested_error.rb +22 -0
  20. data/lib/active_model/railtie.rb +1 -1
  21. data/lib/active_model/secure_password.rb +14 -14
  22. data/lib/active_model/serialization.rb +9 -6
  23. data/lib/active_model/serializers/json.rb +7 -0
  24. data/lib/active_model/type/date_time.rb +2 -2
  25. data/lib/active_model/type/float.rb +2 -0
  26. data/lib/active_model/type/helpers/accepts_multiparameter_time.rb +11 -7
  27. data/lib/active_model/type/helpers/numeric.rb +8 -3
  28. data/lib/active_model/type/helpers/time_value.rb +27 -17
  29. data/lib/active_model/type/helpers/timezone.rb +1 -1
  30. data/lib/active_model/type/immutable_string.rb +14 -10
  31. data/lib/active_model/type/integer.rb +11 -2
  32. data/lib/active_model/type/registry.rb +11 -4
  33. data/lib/active_model/type/string.rb +12 -2
  34. data/lib/active_model/type/value.rb +9 -1
  35. data/lib/active_model/validations.rb +6 -6
  36. data/lib/active_model/validations/absence.rb +1 -1
  37. data/lib/active_model/validations/acceptance.rb +1 -1
  38. data/lib/active_model/validations/clusivity.rb +5 -1
  39. data/lib/active_model/validations/confirmation.rb +2 -2
  40. data/lib/active_model/validations/exclusion.rb +1 -1
  41. data/lib/active_model/validations/format.rb +2 -2
  42. data/lib/active_model/validations/inclusion.rb +1 -1
  43. data/lib/active_model/validations/length.rb +2 -2
  44. data/lib/active_model/validations/numericality.rb +48 -41
  45. data/lib/active_model/validations/presence.rb +1 -1
  46. data/lib/active_model/validations/validates.rb +6 -4
  47. data/lib/active_model/validator.rb +7 -1
  48. metadata +13 -11
@@ -7,9 +7,8 @@ module ActiveModel
7
7
  class AttributeMutationTracker # :nodoc:
8
8
  OPTION_NOT_GIVEN = Object.new
9
9
 
10
- def initialize(attributes, forced_changes = Set.new)
10
+ def initialize(attributes)
11
11
  @attributes = attributes
12
- @forced_changes = forced_changes
13
12
  end
14
13
 
15
14
  def changed_attribute_names
@@ -62,11 +61,15 @@ module ActiveModel
62
61
  end
63
62
 
64
63
  def force_change(attr_name)
65
- forced_changes << attr_name
64
+ forced_changes[attr_name] = fetch_value(attr_name)
66
65
  end
67
66
 
68
67
  private
69
- attr_reader :attributes, :forced_changes
68
+ attr_reader :attributes
69
+
70
+ def forced_changes
71
+ @forced_changes ||= {}
72
+ end
70
73
 
71
74
  def attr_names
72
75
  attributes.keys
@@ -82,7 +85,7 @@ module ActiveModel
82
85
  end
83
86
 
84
87
  class ForcedMutationTracker < AttributeMutationTracker # :nodoc:
85
- def initialize(attributes, forced_changes = {})
88
+ def initialize(attributes)
86
89
  super
87
90
  @finalized_changes = nil
88
91
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/core_ext/enumerable"
3
4
  require "active_support/core_ext/object/deep_dup"
4
5
  require "active_model/attribute_set/builder"
5
6
  require "active_model/attribute_set/yaml_encoder"
@@ -13,11 +14,11 @@ module ActiveModel
13
14
  end
14
15
 
15
16
  def [](name)
16
- attributes[name] || Attribute.null(name)
17
+ @attributes[name] || default_attribute(name)
17
18
  end
18
19
 
19
20
  def []=(name, value)
20
- attributes[name] = value
21
+ @attributes[name] = value
21
22
  end
22
23
 
23
24
  def values_before_type_cast
@@ -25,9 +26,9 @@ module ActiveModel
25
26
  end
26
27
 
27
28
  def to_hash
28
- initialized_attributes.transform_values(&:value)
29
+ keys.index_with { |name| self[name].value }
29
30
  end
30
- alias_method :to_h, :to_hash
31
+ alias :to_h :to_hash
31
32
 
32
33
  def key?(name)
33
34
  attributes.key?(name) && self[name].initialized?
@@ -42,35 +43,36 @@ module ActiveModel
42
43
  end
43
44
 
44
45
  def write_from_database(name, value)
45
- attributes[name] = self[name].with_value_from_database(value)
46
+ @attributes[name] = self[name].with_value_from_database(value)
46
47
  end
47
48
 
48
49
  def write_from_user(name, value)
49
- attributes[name] = self[name].with_value_from_user(value)
50
+ raise FrozenError, "can't modify frozen attributes" if frozen?
51
+ @attributes[name] = self[name].with_value_from_user(value)
52
+ value
50
53
  end
51
54
 
52
55
  def write_cast_value(name, value)
53
- attributes[name] = self[name].with_cast_value(value)
56
+ @attributes[name] = self[name].with_cast_value(value)
57
+ value
54
58
  end
55
59
 
56
60
  def freeze
57
- @attributes.freeze
61
+ attributes.freeze
58
62
  super
59
63
  end
60
64
 
61
65
  def deep_dup
62
- self.class.allocate.tap do |copy|
63
- copy.instance_variable_set(:@attributes, attributes.deep_dup)
64
- end
66
+ AttributeSet.new(attributes.deep_dup)
65
67
  end
66
68
 
67
69
  def initialize_dup(_)
68
- @attributes = attributes.dup
70
+ @attributes = @attributes.dup
69
71
  super
70
72
  end
71
73
 
72
74
  def initialize_clone(_)
73
- @attributes = attributes.clone
75
+ @attributes = @attributes.clone
74
76
  super
75
77
  end
76
78
 
@@ -81,7 +83,7 @@ module ActiveModel
81
83
  end
82
84
 
83
85
  def accessed
84
- attributes.select { |_, attr| attr.has_been_read? }.keys
86
+ attributes.each_key.select { |name| self[name].has_been_read? }
85
87
  end
86
88
 
87
89
  def map(&block)
@@ -97,8 +99,8 @@ module ActiveModel
97
99
  attr_reader :attributes
98
100
 
99
101
  private
100
- def initialized_attributes
101
- attributes.select { |_, attr| attr.initialized? }
102
+ def default_attribute(name)
103
+ Attribute.null(name)
102
104
  end
103
105
  end
104
106
  end
@@ -13,14 +13,86 @@ module ActiveModel
13
13
  end
14
14
 
15
15
  def build_from_database(values = {}, additional_types = {})
16
- attributes = LazyAttributeHash.new(types, values, additional_types, default_attributes)
17
- AttributeSet.new(attributes)
16
+ LazyAttributeSet.new(values, types, additional_types, default_attributes)
18
17
  end
19
18
  end
20
19
  end
21
20
 
21
+ class LazyAttributeSet < AttributeSet # :nodoc:
22
+ def initialize(values, types, additional_types, default_attributes, attributes = {})
23
+ super(attributes)
24
+ @values = values
25
+ @types = types
26
+ @additional_types = additional_types
27
+ @default_attributes = default_attributes
28
+ @casted_values = {}
29
+ @materialized = false
30
+ end
31
+
32
+ def key?(name)
33
+ (values.key?(name) || types.key?(name) || @attributes.key?(name)) && self[name].initialized?
34
+ end
35
+
36
+ def keys
37
+ keys = values.keys | types.keys | @attributes.keys
38
+ keys.keep_if { |name| self[name].initialized? }
39
+ end
40
+
41
+ def fetch_value(name, &block)
42
+ if attr = @attributes[name]
43
+ return attr.value(&block)
44
+ end
45
+
46
+ @casted_values.fetch(name) do
47
+ value_present = true
48
+ value = values.fetch(name) { value_present = false }
49
+
50
+ if value_present
51
+ type = additional_types.fetch(name, types[name])
52
+ @casted_values[name] = type.deserialize(value)
53
+ else
54
+ attr = default_attribute(name, value_present, value)
55
+ attr.value(&block)
56
+ end
57
+ end
58
+ end
59
+
60
+ protected
61
+ def attributes
62
+ unless @materialized
63
+ values.each_key { |key| self[key] }
64
+ types.each_key { |key| self[key] }
65
+ @materialized = true
66
+ end
67
+ @attributes
68
+ end
69
+
70
+ private
71
+ attr_reader :values, :types, :additional_types, :default_attributes
72
+
73
+ def default_attribute(
74
+ name,
75
+ value_present = true,
76
+ value = values.fetch(name) { value_present = false }
77
+ )
78
+ type = additional_types.fetch(name, types[name])
79
+
80
+ if value_present
81
+ @attributes[name] = Attribute.from_database(name, value, type, @casted_values[name])
82
+ elsif types.key?(name)
83
+ if attr = default_attributes[name]
84
+ @attributes[name] = attr.dup
85
+ else
86
+ @attributes[name] = Attribute.uninitialized(name, type)
87
+ end
88
+ else
89
+ Attribute.null(name)
90
+ end
91
+ end
92
+ end
93
+
22
94
  class LazyAttributeHash # :nodoc:
23
- delegate :transform_values, :each_key, :each_value, :fetch, :except, to: :materialize
95
+ delegate :transform_values, :each_value, :fetch, :except, to: :materialize
24
96
 
25
97
  def initialize(types, values, additional_types, default_attributes, delegate_hash = {})
26
98
  @types = types
@@ -40,9 +112,6 @@ module ActiveModel
40
112
  end
41
113
 
42
114
  def []=(key, value)
43
- if frozen?
44
- raise RuntimeError, "Can't modify frozen hash"
45
- end
46
115
  delegate_hash[key] = value
47
116
  end
48
117
 
@@ -57,14 +126,9 @@ module ActiveModel
57
126
  super
58
127
  end
59
128
 
60
- def select
129
+ def each_key(&block)
61
130
  keys = types.keys | values.keys | delegate_hash.keys
62
- keys.each_with_object({}) do |key, hash|
63
- attribute = self[key]
64
- if yield(key, attribute)
65
- hash[key] = attribute
66
- end
67
- end
131
+ keys.each(&block)
68
132
  end
69
133
 
70
134
  def ==(other)
@@ -81,6 +145,9 @@ module ActiveModel
81
145
 
82
146
  def marshal_load(values)
83
147
  if values.is_a?(Hash)
148
+ ActiveSupport::Deprecation.warn(<<~MSG)
149
+ Marshalling load from legacy attributes format is deprecated and will be removed in Rails 6.2.
150
+ MSG
84
151
  empty_hash = {}.freeze
85
152
  initialize(empty_hash, empty_hash, empty_hash, empty_hash, values)
86
153
  @materialized = true
@@ -42,16 +42,14 @@ module ActiveModel
42
42
  end
43
43
 
44
44
  private
45
- def define_method_attribute=(name)
45
+ def define_method_attribute=(name, owner:)
46
46
  ActiveModel::AttributeMethods::AttrNames.define_attribute_accessor_method(
47
- generated_attribute_methods, name, writer: true,
47
+ owner, name, writer: true,
48
48
  ) do |temp_method_name, attr_name_expr|
49
- generated_attribute_methods.module_eval <<-RUBY, __FILE__, __LINE__ + 1
50
- def #{temp_method_name}(value)
51
- name = #{attr_name_expr}
52
- write_attribute(name, value)
53
- end
54
- RUBY
49
+ owner <<
50
+ "def #{temp_method_name}(value)" <<
51
+ " _write_attribute(#{attr_name_expr}, value)" <<
52
+ "end"
55
53
  end
56
54
  end
57
55
 
@@ -79,10 +77,14 @@ module ActiveModel
79
77
  super
80
78
  end
81
79
 
80
+ def initialize_dup(other) # :nodoc:
81
+ @attributes = @attributes.deep_dup
82
+ super
83
+ end
84
+
82
85
  # Returns a hash of all the attributes with their names as keys and the values of the attributes as values.
83
86
  #
84
87
  # class Person
85
- # include ActiveModel::Model
86
88
  # include ActiveModel::Attributes
87
89
  #
88
90
  # attribute :name, :string
@@ -112,25 +114,19 @@ module ActiveModel
112
114
  @attributes.keys
113
115
  end
114
116
 
115
- private
116
- def write_attribute(attr_name, value)
117
- name = attr_name.to_s
118
- name = self.class.attribute_aliases[name] || name
117
+ def freeze
118
+ @attributes = @attributes.clone.freeze unless frozen?
119
+ super
120
+ end
119
121
 
120
- @attributes.write_from_user(name, value)
121
- value
122
+ private
123
+ def _write_attribute(attr_name, value)
124
+ @attributes.write_from_user(attr_name, value)
122
125
  end
126
+ alias :attribute= :_write_attribute
123
127
 
124
128
  def attribute(attr_name)
125
- name = attr_name.to_s
126
- name = self.class.attribute_aliases[name] || name
127
-
128
- @attributes.fetch_value(name)
129
- end
130
-
131
- # Dispatch target for <tt>*=</tt> attribute methods.
132
- def attribute=(attribute_name, value)
133
- write_attribute(attribute_name, value)
129
+ @attributes.fetch_value(attr_name)
134
130
  end
135
131
  end
136
132
  end
@@ -83,7 +83,9 @@ module ActiveModel
83
83
  #
84
84
  # person.previous_changes # => {"name" => [nil, "Bill"]}
85
85
  # person.name_previously_changed? # => true
86
+ # person.name_previously_changed?(from: nil, to: "Bill") # => true
86
87
  # person.name_previous_change # => [nil, "Bill"]
88
+ # person.name_previously_was # => nil
87
89
  # person.reload!
88
90
  # person.previous_changes # => {}
89
91
  #
@@ -122,8 +124,9 @@ module ActiveModel
122
124
 
123
125
  included do
124
126
  attribute_method_suffix "_changed?", "_change", "_will_change!", "_was"
125
- attribute_method_suffix "_previously_changed?", "_previous_change"
127
+ attribute_method_suffix "_previously_changed?", "_previous_change", "_previously_was"
126
128
  attribute_method_affix prefix: "restore_", suffix: "!"
129
+ attribute_method_affix prefix: "clear_", suffix: "_change"
127
130
  end
128
131
 
129
132
  def initialize_dup(other) # :nodoc:
@@ -136,7 +139,7 @@ module ActiveModel
136
139
  @mutations_from_database = nil
137
140
  end
138
141
 
139
- # Clears dirty data and moves +changes+ to +previously_changed+ and
142
+ # Clears dirty data and moves +changes+ to +previous_changes+ and
140
143
  # +mutations_from_database+ to +mutations_before_last_save+ respectively.
141
144
  def changes_applied
142
145
  unless defined?(@attributes)
@@ -176,8 +179,13 @@ module ActiveModel
176
179
  end
177
180
 
178
181
  # Dispatch target for <tt>*_previously_changed?</tt> attribute methods.
179
- def attribute_previously_changed?(attr_name) # :nodoc:
180
- mutations_before_last_save.changed?(attr_name.to_s)
182
+ def attribute_previously_changed?(attr_name, **options) # :nodoc:
183
+ mutations_before_last_save.changed?(attr_name.to_s, **options)
184
+ end
185
+
186
+ # Dispatch target for <tt>*_previously_was</tt> attribute methods.
187
+ def attribute_previously_was(attr_name) # :nodoc:
188
+ mutations_before_last_save.original_value(attr_name.to_s)
181
189
  end
182
190
 
183
191
  # Restore all previous data of the provided attributes.
@@ -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