activemodel 6.0.5.1 → 6.1.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -262
  3. data/MIT-LICENSE +1 -1
  4. data/README.rdoc +2 -2
  5. data/lib/active_model/attribute.rb +15 -14
  6. data/lib/active_model/attribute_assignment.rb +3 -4
  7. data/lib/active_model/attribute_methods.rb +74 -38
  8. data/lib/active_model/attribute_mutation_tracker.rb +8 -5
  9. data/lib/active_model/attribute_set/builder.rb +80 -13
  10. data/lib/active_model/attribute_set.rb +18 -16
  11. data/lib/active_model/attributes.rb +20 -24
  12. data/lib/active_model/dirty.rb +12 -4
  13. data/lib/active_model/error.rb +207 -0
  14. data/lib/active_model/errors.rb +316 -208
  15. data/lib/active_model/gem_version.rb +3 -3
  16. data/lib/active_model/lint.rb +1 -1
  17. data/lib/active_model/naming.rb +2 -2
  18. data/lib/active_model/nested_error.rb +22 -0
  19. data/lib/active_model/railtie.rb +1 -1
  20. data/lib/active_model/secure_password.rb +14 -14
  21. data/lib/active_model/serialization.rb +9 -6
  22. data/lib/active_model/serializers/json.rb +7 -0
  23. data/lib/active_model/type/date_time.rb +2 -2
  24. data/lib/active_model/type/float.rb +2 -0
  25. data/lib/active_model/type/helpers/accepts_multiparameter_time.rb +11 -7
  26. data/lib/active_model/type/helpers/numeric.rb +8 -3
  27. data/lib/active_model/type/helpers/time_value.rb +27 -17
  28. data/lib/active_model/type/helpers/timezone.rb +1 -1
  29. data/lib/active_model/type/immutable_string.rb +14 -10
  30. data/lib/active_model/type/integer.rb +11 -2
  31. data/lib/active_model/type/registry.rb +5 -0
  32. data/lib/active_model/type/string.rb +12 -2
  33. data/lib/active_model/type/value.rb +9 -1
  34. data/lib/active_model/validations/absence.rb +1 -1
  35. data/lib/active_model/validations/acceptance.rb +1 -1
  36. data/lib/active_model/validations/clusivity.rb +5 -1
  37. data/lib/active_model/validations/confirmation.rb +2 -2
  38. data/lib/active_model/validations/exclusion.rb +1 -1
  39. data/lib/active_model/validations/format.rb +2 -2
  40. data/lib/active_model/validations/inclusion.rb +1 -1
  41. data/lib/active_model/validations/length.rb +2 -2
  42. data/lib/active_model/validations/numericality.rb +48 -41
  43. data/lib/active_model/validations/presence.rb +1 -1
  44. data/lib/active_model/validations/validates.rb +6 -4
  45. data/lib/active_model/validations.rb +2 -2
  46. data/lib/active_model/validator.rb +7 -2
  47. data/lib/active_model.rb +2 -1
  48. metadata +15 -14
@@ -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
@@ -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
@@ -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
@@ -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
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