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
@@ -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)