activemodel 5.2.6 → 6.1.4

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 (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
@@ -7,9 +7,9 @@ module ActiveModel
7
7
  end
8
8
 
9
9
  module VERSION
10
- MAJOR = 5
11
- MINOR = 2
12
- TINY = 6
10
+ MAJOR = 6
11
+ MINOR = 1
12
+ TINY = 4
13
13
  PRE = nil
14
14
 
15
15
  STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
@@ -101,7 +101,7 @@ module ActiveModel
101
101
  # locale. If no error is present, the method should return an empty array.
102
102
  def test_errors_aref
103
103
  assert_respond_to model, :errors
104
- assert model.errors[:hello].is_a?(Array), "errors#[] should return an Array"
104
+ assert_equal [], model.errors[:hello], "errors#[] should return an empty Array"
105
105
  end
106
106
 
107
107
  private
@@ -8,7 +8,7 @@ module ActiveModel
8
8
  class Name
9
9
  include Comparable
10
10
 
11
- attr_reader :singular, :plural, :element, :collection,
11
+ attr_accessor :singular, :plural, :element, :collection,
12
12
  :singular_route_key, :route_key, :param_key, :i18n_key,
13
13
  :name
14
14
 
@@ -110,6 +110,22 @@ module ActiveModel
110
110
  # BlogPost.model_name.eql?('BlogPost') # => true
111
111
  # BlogPost.model_name.eql?('Blog Post') # => false
112
112
 
113
+ ##
114
+ # :method: match?
115
+ #
116
+ # :call-seq:
117
+ # match?(regexp)
118
+ #
119
+ # Equivalent to <tt>String#match?</tt>. Match the class name against the
120
+ # given regexp. Returns +true+ if there is a match, otherwise +false+.
121
+ #
122
+ # class BlogPost
123
+ # extend ActiveModel::Naming
124
+ # end
125
+ #
126
+ # BlogPost.model_name.match?(/Post/) # => true
127
+ # BlogPost.model_name.match?(/\d/) # => false
128
+
113
129
  ##
114
130
  # :method: to_s
115
131
  #
@@ -131,7 +147,7 @@ module ActiveModel
131
147
  # to_str()
132
148
  #
133
149
  # Equivalent to +to_s+.
134
- delegate :==, :===, :<=>, :=~, :"!~", :eql?, :to_s,
150
+ delegate :==, :===, :<=>, :=~, :"!~", :eql?, :match?, :to_s,
135
151
  :to_str, :as_json, to: :name
136
152
 
137
153
  # Returns a new ActiveModel::Name instance. By default, the +namespace+
@@ -150,7 +166,7 @@ module ActiveModel
150
166
 
151
167
  raise ArgumentError, "Class name cannot be blank. You need to supply a name argument when anonymous class given" if @name.blank?
152
168
 
153
- @unnamespaced = @name.sub(/^#{namespace.name}::/, "") if namespace
169
+ @unnamespaced = @name.delete_prefix("#{namespace.name}::") if namespace
154
170
  @klass = klass
155
171
  @singular = _singularize(@name)
156
172
  @plural = ActiveSupport::Inflector.pluralize(@singular)
@@ -187,13 +203,12 @@ module ActiveModel
187
203
  defaults << @human
188
204
 
189
205
  options = { scope: [@klass.i18n_scope, :models], count: 1, default: defaults }.merge!(options.except(:default))
190
- I18n.translate(defaults.shift, options)
206
+ I18n.translate(defaults.shift, **options)
191
207
  end
192
208
 
193
209
  private
194
-
195
210
  def _singularize(string)
196
- ActiveSupport::Inflector.underscore(string).tr("/".freeze, "_".freeze)
211
+ ActiveSupport::Inflector.underscore(string).tr("/", "_")
197
212
  end
198
213
  end
199
214
 
@@ -236,7 +251,7 @@ module ActiveModel
236
251
  # Person.model_name.plural # => "people"
237
252
  def model_name
238
253
  @_model_name ||= begin
239
- namespace = parents.detect do |n|
254
+ namespace = module_parents.detect do |n|
240
255
  n.respond_to?(:use_relative_model_naming?) && n.use_relative_model_naming?
241
256
  end
242
257
  ActiveModel::Name.new(self, namespace)
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model/error"
4
+ require "forwardable"
5
+
6
+ module ActiveModel
7
+ class NestedError < Error
8
+ def initialize(base, inner_error, override_options = {})
9
+ @base = base
10
+ @inner_error = inner_error
11
+ @attribute = override_options.fetch(:attribute) { inner_error.attribute }
12
+ @type = override_options.fetch(:type) { inner_error.type }
13
+ @raw_type = inner_error.raw_type
14
+ @options = inner_error.options
15
+ end
16
+
17
+ attr_reader :inner_error
18
+
19
+ extend Forwardable
20
+ def_delegators :@inner_error, :message
21
+ end
22
+ end
@@ -7,8 +7,14 @@ module ActiveModel
7
7
  class Railtie < Rails::Railtie # :nodoc:
8
8
  config.eager_load_namespaces << ActiveModel
9
9
 
10
+ config.active_model = ActiveSupport::OrderedOptions.new
11
+
10
12
  initializer "active_model.secure_password" do
11
13
  ActiveModel::SecurePassword.min_cost = Rails.env.test?
12
14
  end
15
+
16
+ initializer "active_model.i18n_customize_full_message" do
17
+ ActiveModel::Error.i18n_customize_full_message = config.active_model.delete(:i18n_customize_full_message) || false
18
+ end
13
19
  end
14
20
  end
@@ -16,15 +16,16 @@ module ActiveModel
16
16
 
17
17
  module ClassMethods
18
18
  # Adds methods to set and authenticate against a BCrypt password.
19
- # This mechanism requires you to have a +password_digest+ attribute.
19
+ # This mechanism requires you to have a +XXX_digest+ attribute.
20
+ # Where +XXX+ is the attribute name of your desired password.
20
21
  #
21
22
  # The following validations are added automatically:
22
23
  # * Password must be present on creation
23
24
  # * Password length should be less than or equal to 72 bytes
24
- # * Confirmation of password (using a +password_confirmation+ attribute)
25
+ # * Confirmation of password (using a +XXX_confirmation+ attribute)
25
26
  #
26
- # If password confirmation validation is not needed, simply leave out the
27
- # value for +password_confirmation+ (i.e. don't provide a form field for
27
+ # If confirmation validation is not needed, simply leave out the
28
+ # value for +XXX_confirmation+ (i.e. don't provide a form field for
28
29
  # it). When this attribute has a +nil+ value, the validation will not be
29
30
  # triggered.
30
31
  #
@@ -37,22 +38,27 @@ module ActiveModel
37
38
  #
38
39
  # Example using Active Record (which automatically includes ActiveModel::SecurePassword):
39
40
  #
40
- # # Schema: User(name:string, password_digest:string)
41
+ # # Schema: User(name:string, password_digest:string, recovery_password_digest:string)
41
42
  # class User < ActiveRecord::Base
42
43
  # has_secure_password
44
+ # has_secure_password :recovery_password, validations: false
43
45
  # end
44
46
  #
45
47
  # user = User.new(name: 'david', password: '', password_confirmation: 'nomatch')
46
- # user.save # => false, password required
48
+ # user.save # => false, password required
47
49
  # user.password = 'mUc3m00RsqyRe'
48
- # user.save # => false, confirmation doesn't match
50
+ # user.save # => false, confirmation doesn't match
49
51
  # user.password_confirmation = 'mUc3m00RsqyRe'
50
- # user.save # => true
51
- # user.authenticate('notright') # => false
52
- # user.authenticate('mUc3m00RsqyRe') # => user
53
- # User.find_by(name: 'david').try(:authenticate, 'notright') # => false
54
- # User.find_by(name: 'david').try(:authenticate, 'mUc3m00RsqyRe') # => user
55
- def has_secure_password(options = {})
52
+ # user.save # => true
53
+ # user.recovery_password = "42password"
54
+ # user.recovery_password_digest # => "$2a$04$iOfhwahFymCs5weB3BNH/uXkTG65HR.qpW.bNhEjFP3ftli3o5DQC"
55
+ # user.save # => true
56
+ # user.authenticate('notright') # => false
57
+ # user.authenticate('mUc3m00RsqyRe') # => user
58
+ # user.authenticate_recovery_password('42password') # => user
59
+ # User.find_by(name: 'david')&.authenticate('notright') # => false
60
+ # User.find_by(name: 'david')&.authenticate('mUc3m00RsqyRe') # => user
61
+ def has_secure_password(attribute = :password, validations: true)
56
62
  # Load bcrypt gem only when has_secure_password is used.
57
63
  # This is to avoid ActiveModel (and by extension the entire framework)
58
64
  # being dependent on a binary library.
@@ -63,9 +69,9 @@ module ActiveModel
63
69
  raise
64
70
  end
65
71
 
66
- include InstanceMethodsOnActivation
72
+ include InstanceMethodsOnActivation.new(attribute)
67
73
 
68
- if options.fetch(:validations, true)
74
+ if validations
69
75
  include ActiveModel::Validations
70
76
 
71
77
  # This ensures the model has a password by checking whether the password_digest
@@ -73,56 +79,49 @@ module ActiveModel
73
79
  # when there is an error, the message is added to the password attribute instead
74
80
  # so that the error message will make sense to the end-user.
75
81
  validate do |record|
76
- record.errors.add(:password, :blank) unless record.password_digest.present?
82
+ record.errors.add(attribute, :blank) unless record.public_send("#{attribute}_digest").present?
77
83
  end
78
84
 
79
- validates_length_of :password, maximum: ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED
80
- validates_confirmation_of :password, allow_blank: true
85
+ validates_length_of attribute, maximum: ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED
86
+ validates_confirmation_of attribute, allow_blank: true
81
87
  end
82
88
  end
83
89
  end
84
90
 
85
- module InstanceMethodsOnActivation
86
- # Returns +self+ if the password is correct, otherwise +false+.
87
- #
88
- # class User < ActiveRecord::Base
89
- # has_secure_password validations: false
90
- # end
91
- #
92
- # user = User.new(name: 'david', password: 'mUc3m00RsqyRe')
93
- # user.save
94
- # user.authenticate('notright') # => false
95
- # user.authenticate('mUc3m00RsqyRe') # => user
96
- def authenticate(unencrypted_password)
97
- BCrypt::Password.new(password_digest).is_password?(unencrypted_password) && self
98
- end
91
+ class InstanceMethodsOnActivation < Module
92
+ def initialize(attribute)
93
+ attr_reader attribute
99
94
 
100
- attr_reader :password
95
+ define_method("#{attribute}=") do |unencrypted_password|
96
+ if unencrypted_password.nil?
97
+ self.public_send("#{attribute}_digest=", nil)
98
+ elsif !unencrypted_password.empty?
99
+ instance_variable_set("@#{attribute}", unencrypted_password)
100
+ cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost
101
+ self.public_send("#{attribute}_digest=", BCrypt::Password.create(unencrypted_password, cost: cost))
102
+ end
103
+ end
101
104
 
102
- # Encrypts the password into the +password_digest+ attribute, only if the
103
- # new password is not empty.
104
- #
105
- # class User < ActiveRecord::Base
106
- # has_secure_password validations: false
107
- # end
108
- #
109
- # user = User.new
110
- # user.password = nil
111
- # user.password_digest # => nil
112
- # user.password = 'mUc3m00RsqyRe'
113
- # user.password_digest # => "$2a$10$4LEA7r4YmNHtvlAvHhsYAeZmk/xeUVtMTYqwIvYY76EW5GUqDiP4."
114
- def password=(unencrypted_password)
115
- if unencrypted_password.nil?
116
- self.password_digest = nil
117
- elsif !unencrypted_password.empty?
118
- @password = unencrypted_password
119
- cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost
120
- self.password_digest = BCrypt::Password.create(unencrypted_password, cost: cost)
105
+ define_method("#{attribute}_confirmation=") do |unencrypted_password|
106
+ instance_variable_set("@#{attribute}_confirmation", unencrypted_password)
107
+ end
108
+
109
+ # Returns +self+ if the password is correct, otherwise +false+.
110
+ #
111
+ # class User < ActiveRecord::Base
112
+ # has_secure_password validations: false
113
+ # end
114
+ #
115
+ # user = User.new(name: 'david', password: 'mUc3m00RsqyRe')
116
+ # user.save
117
+ # user.authenticate_password('notright') # => false
118
+ # user.authenticate_password('mUc3m00RsqyRe') # => user
119
+ define_method("authenticate_#{attribute}") do |unencrypted_password|
120
+ attribute_digest = public_send("#{attribute}_digest")
121
+ BCrypt::Password.new(attribute_digest).is_password?(unencrypted_password) && self
121
122
  end
122
- end
123
123
 
124
- def password_confirmation=(unencrypted_password)
125
- @password_confirmation = unencrypted_password
124
+ alias_method :authenticate, :authenticate_password if attribute == :password
126
125
  end
127
126
  end
128
127
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support/core_ext/hash/except"
4
- require "active_support/core_ext/hash/slice"
3
+ require "active_support/core_ext/enumerable"
5
4
 
6
5
  module ActiveModel
7
6
  # == Active \Model \Serialization
@@ -124,17 +123,17 @@ module ActiveModel
124
123
  # user.serializable_hash(include: { notes: { only: 'title' }})
125
124
  # # => {"name" => "Napoleon", "notes" => [{"title"=>"Battle of Austerlitz"}]}
126
125
  def serializable_hash(options = nil)
127
- options ||= {}
128
-
129
126
  attribute_names = attributes.keys
127
+
128
+ return serializable_attributes(attribute_names) if options.blank?
129
+
130
130
  if only = options[:only]
131
131
  attribute_names &= Array(only).map(&:to_s)
132
132
  elsif except = options[:except]
133
133
  attribute_names -= Array(except).map(&:to_s)
134
134
  end
135
135
 
136
- hash = {}
137
- attribute_names.each { |n| hash[n] = read_attribute_for_serialization(n) }
136
+ hash = serializable_attributes(attribute_names)
138
137
 
139
138
  Array(options[:methods]).each { |m| hash[m.to_s] = send(m) }
140
139
 
@@ -150,7 +149,6 @@ module ActiveModel
150
149
  end
151
150
 
152
151
  private
153
-
154
152
  # Hook method defining how an attribute value should be retrieved for
155
153
  # serialization. By default this is assumed to be an instance named after
156
154
  # the attribute. Override this method in subclasses should you need to
@@ -169,6 +167,10 @@ module ActiveModel
169
167
  # end
170
168
  alias :read_attribute_for_serialization :send
171
169
 
170
+ def serializable_attributes(attribute_names)
171
+ attribute_names.index_with { |n| read_attribute_for_serialization(n) }
172
+ end
173
+
172
174
  # Add associations specified via the <tt>:include</tt> option.
173
175
  #
174
176
  # Expects a block that takes as arguments:
@@ -26,13 +26,13 @@ module ActiveModel
26
26
  # user = User.find(1)
27
27
  # user.as_json
28
28
  # # => { "id" => 1, "name" => "Konata Izumi", "age" => 16,
29
- # # "created_at" => "2006/08/01", "awesome" => true}
29
+ # # "created_at" => "2006-08-01T17:27:133.000Z", "awesome" => true}
30
30
  #
31
31
  # ActiveRecord::Base.include_root_in_json = true
32
32
  #
33
33
  # user.as_json
34
34
  # # => { "user" => { "id" => 1, "name" => "Konata Izumi", "age" => 16,
35
- # # "created_at" => "2006/08/01", "awesome" => true } }
35
+ # # "created_at" => "2006-08-01T17:27:13.000Z", "awesome" => true } }
36
36
  #
37
37
  # This behavior can also be achieved by setting the <tt>:root</tt> option
38
38
  # to +true+ as in:
@@ -40,7 +40,14 @@ module ActiveModel
40
40
  # user = User.find(1)
41
41
  # user.as_json(root: true)
42
42
  # # => { "user" => { "id" => 1, "name" => "Konata Izumi", "age" => 16,
43
- # # "created_at" => "2006/08/01", "awesome" => true } }
43
+ # # "created_at" => "2006-08-01T17:27:13.000Z", "awesome" => true } }
44
+ #
45
+ # If you prefer, <tt>:root</tt> may also be set to a custom string key instead as in:
46
+ #
47
+ # user = User.find(1)
48
+ # user.as_json(root: "author")
49
+ # # => { "author" => { "id" => 1, "name" => "Konata Izumi", "age" => 16,
50
+ # # "created_at" => "2006-08-01T17:27:13.000Z", "awesome" => true } }
44
51
  #
45
52
  # Without any +options+, the returned Hash will include all the model's
46
53
  # attributes.
@@ -48,7 +55,7 @@ module ActiveModel
48
55
  # user = User.find(1)
49
56
  # user.as_json
50
57
  # # => { "id" => 1, "name" => "Konata Izumi", "age" => 16,
51
- # # "created_at" => "2006/08/01", "awesome" => true}
58
+ # # "created_at" => "2006-08-01T17:27:13.000Z", "awesome" => true}
52
59
  #
53
60
  # The <tt>:only</tt> and <tt>:except</tt> options can be used to limit
54
61
  # the attributes included, and work similar to the +attributes+ method.
@@ -63,14 +70,14 @@ module ActiveModel
63
70
  #
64
71
  # user.as_json(methods: :permalink)
65
72
  # # => { "id" => 1, "name" => "Konata Izumi", "age" => 16,
66
- # # "created_at" => "2006/08/01", "awesome" => true,
73
+ # # "created_at" => "2006-08-01T17:27:13.000Z", "awesome" => true,
67
74
  # # "permalink" => "1-konata-izumi" }
68
75
  #
69
76
  # To include associations use <tt>:include</tt>:
70
77
  #
71
78
  # user.as_json(include: :posts)
72
79
  # # => { "id" => 1, "name" => "Konata Izumi", "age" => 16,
73
- # # "created_at" => "2006/08/01", "awesome" => true,
80
+ # # "created_at" => "2006-08-01T17:27:13.000Z", "awesome" => true,
74
81
  # # "posts" => [ { "id" => 1, "author_id" => 1, "title" => "Welcome to the weblog" },
75
82
  # # { "id" => 2, "author_id" => 1, "title" => "So I was thinking" } ] }
76
83
  #
@@ -81,7 +88,7 @@ module ActiveModel
81
88
  # only: :body } },
82
89
  # only: :title } })
83
90
  # # => { "id" => 1, "name" => "Konata Izumi", "age" => 16,
84
- # # "created_at" => "2006/08/01", "awesome" => true,
91
+ # # "created_at" => "2006-08-01T17:27:13.000Z", "awesome" => true,
85
92
  # # "posts" => [ { "comments" => [ { "body" => "1st post!" }, { "body" => "Second!" } ],
86
93
  # # "title" => "Welcome to the weblog" },
87
94
  # # { "comments" => [ { "body" => "Don't think too hard" } ],
@@ -93,11 +100,12 @@ module ActiveModel
93
100
  include_root_in_json
94
101
  end
95
102
 
103
+ hash = serializable_hash(options).as_json
96
104
  if root
97
105
  root = model_name.element if root == true
98
- { root => serializable_hash(options) }
106
+ { root => hash }
99
107
  else
100
- serializable_hash(options)
108
+ hash
101
109
  end
102
110
  end
103
111
 
@@ -64,7 +64,7 @@ module ActiveModel
64
64
  defaults << attribute.humanize
65
65
 
66
66
  options[:default] = defaults
67
- I18n.translate(defaults.shift, options)
67
+ I18n.translate(defaults.shift, **options)
68
68
  end
69
69
  end
70
70
  end
@@ -6,7 +6,6 @@ module ActiveModel
6
6
  module Type
7
7
  class BigInteger < Integer # :nodoc:
8
8
  private
9
-
10
9
  def max_value
11
10
  ::Float::INFINITY
12
11
  end
@@ -40,7 +40,7 @@ module ActiveModel
40
40
  alias_method :to_str, :to_s
41
41
 
42
42
  def hex
43
- @value.unpack("H*")[0]
43
+ @value.unpack1("H*")
44
44
  end
45
45
 
46
46
  def ==(other)