activemodel 4.2.0 → 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 (71) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +49 -37
  3. data/MIT-LICENSE +1 -1
  4. data/README.rdoc +16 -22
  5. data/lib/active_model/attribute/user_provided_default.rb +51 -0
  6. data/lib/active_model/attribute.rb +248 -0
  7. data/lib/active_model/attribute_assignment.rb +55 -0
  8. data/lib/active_model/attribute_methods.rb +150 -73
  9. data/lib/active_model/attribute_mutation_tracker.rb +181 -0
  10. data/lib/active_model/attribute_set/builder.rb +191 -0
  11. data/lib/active_model/attribute_set/yaml_encoder.rb +40 -0
  12. data/lib/active_model/attribute_set.rb +106 -0
  13. data/lib/active_model/attributes.rb +132 -0
  14. data/lib/active_model/callbacks.rb +31 -25
  15. data/lib/active_model/conversion.rb +20 -9
  16. data/lib/active_model/dirty.rb +142 -116
  17. data/lib/active_model/error.rb +207 -0
  18. data/lib/active_model/errors.rb +436 -202
  19. data/lib/active_model/forbidden_attributes_protection.rb +6 -3
  20. data/lib/active_model/gem_version.rb +5 -3
  21. data/lib/active_model/lint.rb +47 -42
  22. data/lib/active_model/locale/en.yml +2 -1
  23. data/lib/active_model/model.rb +7 -7
  24. data/lib/active_model/naming.rb +36 -18
  25. data/lib/active_model/nested_error.rb +22 -0
  26. data/lib/active_model/railtie.rb +8 -0
  27. data/lib/active_model/secure_password.rb +61 -67
  28. data/lib/active_model/serialization.rb +48 -17
  29. data/lib/active_model/serializers/json.rb +22 -13
  30. data/lib/active_model/translation.rb +5 -4
  31. data/lib/active_model/type/big_integer.rb +14 -0
  32. data/lib/active_model/type/binary.rb +52 -0
  33. data/lib/active_model/type/boolean.rb +46 -0
  34. data/lib/active_model/type/date.rb +52 -0
  35. data/lib/active_model/type/date_time.rb +46 -0
  36. data/lib/active_model/type/decimal.rb +69 -0
  37. data/lib/active_model/type/float.rb +35 -0
  38. data/lib/active_model/type/helpers/accepts_multiparameter_time.rb +49 -0
  39. data/lib/active_model/type/helpers/mutable.rb +20 -0
  40. data/lib/active_model/type/helpers/numeric.rb +48 -0
  41. data/lib/active_model/type/helpers/time_value.rb +90 -0
  42. data/lib/active_model/type/helpers/timezone.rb +19 -0
  43. data/lib/active_model/type/helpers.rb +7 -0
  44. data/lib/active_model/type/immutable_string.rb +35 -0
  45. data/lib/active_model/type/integer.rb +67 -0
  46. data/lib/active_model/type/registry.rb +70 -0
  47. data/lib/active_model/type/string.rb +35 -0
  48. data/lib/active_model/type/time.rb +46 -0
  49. data/lib/active_model/type/value.rb +133 -0
  50. data/lib/active_model/type.rb +53 -0
  51. data/lib/active_model/validations/absence.rb +6 -4
  52. data/lib/active_model/validations/acceptance.rb +72 -14
  53. data/lib/active_model/validations/callbacks.rb +23 -19
  54. data/lib/active_model/validations/clusivity.rb +18 -12
  55. data/lib/active_model/validations/confirmation.rb +27 -14
  56. data/lib/active_model/validations/exclusion.rb +7 -4
  57. data/lib/active_model/validations/format.rb +27 -27
  58. data/lib/active_model/validations/helper_methods.rb +15 -0
  59. data/lib/active_model/validations/inclusion.rb +8 -7
  60. data/lib/active_model/validations/length.rb +35 -32
  61. data/lib/active_model/validations/numericality.rb +72 -34
  62. data/lib/active_model/validations/presence.rb +3 -3
  63. data/lib/active_model/validations/validates.rb +17 -15
  64. data/lib/active_model/validations/with.rb +6 -12
  65. data/lib/active_model/validations.rb +58 -23
  66. data/lib/active_model/validator.rb +23 -17
  67. data/lib/active_model/version.rb +4 -2
  68. data/lib/active_model.rb +18 -11
  69. metadata +44 -25
  70. data/lib/active_model/serializers/xml.rb +0 -238
  71. data/lib/active_model/test_case.rb +0 -4
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ActiveModel
2
4
  # Raised when forbidden attributes are used for mass assignment.
3
5
  #
@@ -15,10 +17,11 @@ module ActiveModel
15
17
  end
16
18
 
17
19
  module ForbiddenAttributesProtection # :nodoc:
18
- protected
20
+ private
19
21
  def sanitize_for_mass_assignment(attributes)
20
- if attributes.respond_to?(:permitted?) && !attributes.permitted?
21
- raise ActiveModel::ForbiddenAttributesError
22
+ if attributes.respond_to?(:permitted?)
23
+ raise ActiveModel::ForbiddenAttributesError if !attributes.permitted?
24
+ attributes.to_h
22
25
  else
23
26
  attributes
24
27
  end
@@ -1,12 +1,14 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ActiveModel
2
- # Returns the version of the currently loaded Active Model as a <tt>Gem::Version</tt>
4
+ # Returns the version of the currently loaded \Active \Model as a <tt>Gem::Version</tt>
3
5
  def self.gem_version
4
6
  Gem::Version.new VERSION::STRING
5
7
  end
6
8
 
7
9
  module VERSION
8
- MAJOR = 4
9
- MINOR = 2
10
+ MAJOR = 6
11
+ MINOR = 1
10
12
  TINY = 0
11
13
  PRE = nil
12
14
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ActiveModel
2
4
  module Lint
3
5
  # == Active \Model \Lint \Tests
@@ -20,88 +22,91 @@ module ActiveModel
20
22
  # to <tt>to_model</tt>. It is perfectly fine for <tt>to_model</tt> to return
21
23
  # +self+.
22
24
  module Tests
23
-
24
- # == Responds to <tt>to_key</tt>
25
+ # Passes if the object's model responds to <tt>to_key</tt> and if calling
26
+ # this method returns +nil+ when the object is not persisted.
27
+ # Fails otherwise.
25
28
  #
26
- # Returns an Enumerable of all (primary) key attributes
27
- # or nil if <tt>model.persisted?</tt> is false. This is used by
28
- # <tt>dom_id</tt> to generate unique ids for the object.
29
+ # <tt>to_key</tt> returns an Enumerable of all (primary) key attributes
30
+ # of the model, and is used to a generate unique DOM id for the object.
29
31
  def test_to_key
30
- assert model.respond_to?(:to_key), "The model should respond to to_key"
32
+ assert_respond_to model, :to_key
31
33
  def model.persisted?() false end
32
34
  assert model.to_key.nil?, "to_key should return nil when `persisted?` returns false"
33
35
  end
34
36
 
35
- # == Responds to <tt>to_param</tt>
36
- #
37
- # Returns a string representing the object's key suitable for use in URLs
38
- # or +nil+ if <tt>model.persisted?</tt> is +false+.
37
+ # Passes if the object's model responds to <tt>to_param</tt> and if
38
+ # calling this method returns +nil+ when the object is not persisted.
39
+ # Fails otherwise.
39
40
  #
41
+ # <tt>to_param</tt> is used to represent the object's key in URLs.
40
42
  # Implementers can decide to either raise an exception or provide a
41
43
  # default in case the record uses a composite primary key. There are no
42
44
  # tests for this behavior in lint because it doesn't make sense to force
43
45
  # any of the possible implementation strategies on the implementer.
44
- # However, if the resource is not persisted?, then <tt>to_param</tt>
45
- # should always return +nil+.
46
46
  def test_to_param
47
- assert model.respond_to?(:to_param), "The model should respond to to_param"
47
+ assert_respond_to model, :to_param
48
48
  def model.to_key() [1] end
49
49
  def model.persisted?() false end
50
50
  assert model.to_param.nil?, "to_param should return nil when `persisted?` returns false"
51
51
  end
52
52
 
53
- # == Responds to <tt>to_partial_path</tt>
53
+ # Passes if the object's model responds to <tt>to_partial_path</tt> and if
54
+ # calling this method returns a string. Fails otherwise.
54
55
  #
55
- # Returns a string giving a relative path. This is used for looking up
56
- # partials. For example, a BlogPost model might return "blog_posts/blog_post"
56
+ # <tt>to_partial_path</tt> is used for looking up partials. For example,
57
+ # a BlogPost model might return "blog_posts/blog_post".
57
58
  def test_to_partial_path
58
- assert model.respond_to?(:to_partial_path), "The model should respond to to_partial_path"
59
+ assert_respond_to model, :to_partial_path
59
60
  assert_kind_of String, model.to_partial_path
60
61
  end
61
62
 
62
- # == Responds to <tt>persisted?</tt>
63
+ # Passes if the object's model responds to <tt>persisted?</tt> and if
64
+ # calling this method returns either +true+ or +false+. Fails otherwise.
63
65
  #
64
- # Returns a boolean that specifies whether the object has been persisted
65
- # yet. This is used when calculating the URL for an object. If the object
66
- # is not persisted, a form for that object, for instance, will route to
67
- # the create action. If it is persisted, a form for the object will routes
68
- # to the update action.
66
+ # <tt>persisted?</tt> is used when calculating the URL for an object.
67
+ # If the object is not persisted, a form for that object, for instance,
68
+ # will route to the create action. If it is persisted, a form for the
69
+ # object will route to the update action.
69
70
  def test_persisted?
70
- assert model.respond_to?(:persisted?), "The model should respond to persisted?"
71
+ assert_respond_to model, :persisted?
71
72
  assert_boolean model.persisted?, "persisted?"
72
73
  end
73
74
 
74
- # == \Naming
75
+ # Passes if the object's model responds to <tt>model_name</tt> both as
76
+ # an instance method and as a class method, and if calling this method
77
+ # returns a string with some convenience methods: <tt>:human</tt>,
78
+ # <tt>:singular</tt> and <tt>:plural</tt>.
75
79
  #
76
- # Model.model_name and Model#model_name must return a string with some
77
- # convenience methods: # <tt>:human</tt>, <tt>:singular</tt> and
78
- # <tt>:plural</tt>. Check ActiveModel::Naming for more information.
80
+ # Check ActiveModel::Naming for more information.
79
81
  def test_model_naming
80
- assert model.class.respond_to?(:model_name), "The model class should respond to model_name"
82
+ assert_respond_to model.class, :model_name
81
83
  model_name = model.class.model_name
82
- assert model_name.respond_to?(:to_str)
83
- assert model_name.human.respond_to?(:to_str)
84
- assert model_name.singular.respond_to?(:to_str)
85
- assert model_name.plural.respond_to?(:to_str)
84
+ assert_respond_to model_name, :to_str
85
+ assert_respond_to model_name.human, :to_str
86
+ assert_respond_to model_name.singular, :to_str
87
+ assert_respond_to model_name.plural, :to_str
86
88
 
87
- assert model.respond_to?(:model_name), "The model instance should respond to model_name"
89
+ assert_respond_to model, :model_name
88
90
  assert_equal model.model_name, model.class.model_name
89
91
  end
90
92
 
91
- # == \Errors Testing
93
+ # Passes if the object's model responds to <tt>errors</tt> and if calling
94
+ # <tt>[](attribute)</tt> on the result of this method returns an array.
95
+ # Fails otherwise.
92
96
  #
93
- # Returns an object that implements [](attribute) defined which returns an
94
- # Array of Strings that are the errors for the attribute in question.
95
- # If localization is used, the Strings should be localized for the current
96
- # locale. If no error is present, this method should return an empty Array.
97
+ # <tt>errors[attribute]</tt> is used to retrieve the errors of a model
98
+ # for a given attribute. If errors are present, the method should return
99
+ # an array of strings that are the errors for the attribute in question.
100
+ # If localization is used, the strings should be localized for the current
101
+ # locale. If no error is present, the method should return an empty array.
97
102
  def test_errors_aref
98
- assert model.respond_to?(:errors), "The model should respond to errors"
99
- assert model.errors[:hello].is_a?(Array), "errors#[] should return an Array"
103
+ assert_respond_to model, :errors
104
+ assert_equal [], model.errors[:hello], "errors#[] should return an empty Array"
100
105
  end
101
106
 
102
107
  private
103
108
  def model
104
- assert @model.respond_to?(:to_model), "The object should respond to to_model"
109
+ assert_respond_to @model, :to_model
105
110
  @model.to_model
106
111
  end
107
112
 
@@ -6,6 +6,7 @@ en:
6
6
  # The values :model, :attribute and :value are always available for interpolation
7
7
  # The value :count is available when applicable. Can be used for pluralization.
8
8
  messages:
9
+ model_invalid: "Validation failed: %{errors}"
9
10
  inclusion: "is not included in the list"
10
11
  exclusion: "is reserved"
11
12
  invalid: "is invalid"
@@ -16,7 +17,7 @@ en:
16
17
  present: "must be blank"
17
18
  too_long:
18
19
  one: "is too long (maximum is 1 character)"
19
- other: "is too long (maximum is %{count} characters)"
20
+ other: "is too long (maximum is %{count} characters)"
20
21
  too_short:
21
22
  one: "is too short (minimum is 1 character)"
22
23
  other: "is too short (minimum is %{count} characters)"
@@ -1,12 +1,13 @@
1
- module ActiveModel
1
+ # frozen_string_literal: true
2
2
 
3
+ module ActiveModel
3
4
  # == Active \Model \Basic \Model
4
5
  #
5
6
  # Includes the required interface for an object to interact with
6
- # <tt>ActionPack</tt>, using different <tt>ActiveModel</tt> modules.
7
+ # Action Pack and Action View, using different Active Model modules.
7
8
  # It includes model name introspections, conversions, translations and
8
9
  # validations. Besides that, it allows you to initialize the object with a
9
- # hash of attributes, pretty much like <tt>ActiveRecord</tt> does.
10
+ # hash of attributes, pretty much like Active Record does.
10
11
  #
11
12
  # A minimal implementation could be:
12
13
  #
@@ -57,6 +58,7 @@ module ActiveModel
57
58
  # (see below).
58
59
  module Model
59
60
  extend ActiveSupport::Concern
61
+ include ActiveModel::AttributeAssignment
60
62
  include ActiveModel::Validations
61
63
  include ActiveModel::Conversion
62
64
 
@@ -75,10 +77,8 @@ module ActiveModel
75
77
  # person = Person.new(name: 'bob', age: '18')
76
78
  # person.name # => "bob"
77
79
  # person.age # => "18"
78
- def initialize(params={})
79
- params.each do |attr, value|
80
- self.public_send("#{attr}=", value)
81
- end if params
80
+ def initialize(attributes = {})
81
+ assign_attributes(attributes) if attributes
82
82
 
83
83
  super()
84
84
  end
@@ -1,11 +1,14 @@
1
- require 'active_support/core_ext/hash/except'
2
- require 'active_support/core_ext/module/introspection'
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/hash/except"
4
+ require "active_support/core_ext/module/introspection"
5
+ require "active_support/core_ext/module/redefine_method"
3
6
 
4
7
  module ActiveModel
5
8
  class Name
6
9
  include Comparable
7
10
 
8
- attr_reader :singular, :plural, :element, :collection,
11
+ attr_accessor :singular, :plural, :element, :collection,
9
12
  :singular_route_key, :route_key, :param_key, :i18n_key,
10
13
  :name
11
14
 
@@ -46,7 +49,7 @@ module ActiveModel
46
49
  # :method: <=>
47
50
  #
48
51
  # :call-seq:
49
- # ==(other)
52
+ # <=>(other)
50
53
  #
51
54
  # Equivalent to <tt>String#<=></tt>.
52
55
  #
@@ -107,6 +110,22 @@ module ActiveModel
107
110
  # BlogPost.model_name.eql?('BlogPost') # => true
108
111
  # BlogPost.model_name.eql?('Blog Post') # => false
109
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
+
110
129
  ##
111
130
  # :method: to_s
112
131
  #
@@ -128,8 +147,8 @@ module ActiveModel
128
147
  # to_str()
129
148
  #
130
149
  # Equivalent to +to_s+.
131
- delegate :==, :===, :<=>, :=~, :"!~", :eql?, :to_s,
132
- :to_str, to: :name
150
+ delegate :==, :===, :<=>, :=~, :"!~", :eql?, :match?, :to_s,
151
+ :to_str, :as_json, to: :name
133
152
 
134
153
  # Returns a new ActiveModel::Name instance. By default, the +namespace+
135
154
  # and +name+ option will take the namespace and name of the given class
@@ -147,7 +166,7 @@ module ActiveModel
147
166
 
148
167
  raise ArgumentError, "Class name cannot be blank. You need to supply a name argument when anonymous class given" if @name.blank?
149
168
 
150
- @unnamespaced = @name.sub(/^#{namespace.name}::/, '') if namespace
169
+ @unnamespaced = @name.delete_prefix("#{namespace.name}::") if namespace
151
170
  @klass = klass
152
171
  @singular = _singularize(@name)
153
172
  @plural = ActiveSupport::Inflector.pluralize(@singular)
@@ -162,7 +181,7 @@ module ActiveModel
162
181
  @route_key << "_index" if @plural == @singular
163
182
  end
164
183
 
165
- # Transform the model name into a more humane format, using I18n. By default,
184
+ # Transform the model name into a more human format, using I18n. By default,
166
185
  # it will underscore then humanize the class name.
167
186
  #
168
187
  # class BlogPost
@@ -172,7 +191,7 @@ module ActiveModel
172
191
  # BlogPost.model_name.human # => "Blog post"
173
192
  #
174
193
  # Specify +options+ with additional translating options.
175
- def human(options={})
194
+ def human(options = {})
176
195
  return @human unless @klass.respond_to?(:lookup_ancestors) &&
177
196
  @klass.respond_to?(:i18n_scope)
178
197
 
@@ -184,14 +203,13 @@ module ActiveModel
184
203
  defaults << @human
185
204
 
186
205
  options = { scope: [@klass.i18n_scope, :models], count: 1, default: defaults }.merge!(options.except(:default))
187
- I18n.translate(defaults.shift, options)
206
+ I18n.translate(defaults.shift, **options)
188
207
  end
189
208
 
190
209
  private
191
-
192
- def _singularize(string, replacement='_')
193
- ActiveSupport::Inflector.underscore(string).tr('/', replacement)
194
- end
210
+ def _singularize(string)
211
+ ActiveSupport::Inflector.underscore(string).tr("/", "_")
212
+ end
195
213
  end
196
214
 
197
215
  # == Active \Model \Naming
@@ -211,11 +229,11 @@ module ActiveModel
211
229
  # BookModule::BookCover.model_name.i18n_key # => :"book_module/book_cover"
212
230
  #
213
231
  # Providing the functionality that ActiveModel::Naming provides in your object
214
- # is required to pass the Active Model Lint test. So either extending the
232
+ # is required to pass the \Active \Model Lint test. So either extending the
215
233
  # provided method below, or rolling your own is required.
216
234
  module Naming
217
235
  def self.extended(base) #:nodoc:
218
- base.remove_possible_method :model_name
236
+ base.silence_redefinition_of_method :model_name
219
237
  base.delegate :model_name, to: :class
220
238
  end
221
239
 
@@ -224,7 +242,7 @@ module ActiveModel
224
242
  # (See ActiveModel::Name for more information).
225
243
  #
226
244
  # class Person
227
- # include ActiveModel::Model
245
+ # extend ActiveModel::Naming
228
246
  # end
229
247
  #
230
248
  # Person.model_name.name # => "Person"
@@ -233,7 +251,7 @@ module ActiveModel
233
251
  # Person.model_name.plural # => "people"
234
252
  def model_name
235
253
  @_model_name ||= begin
236
- namespace = self.parents.detect do |n|
254
+ namespace = module_parents.detect do |n|
237
255
  n.respond_to?(:use_relative_model_naming?) && n.use_relative_model_naming?
238
256
  end
239
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
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "active_model"
2
4
  require "rails"
3
5
 
@@ -5,8 +7,14 @@ module ActiveModel
5
7
  class Railtie < Rails::Railtie # :nodoc:
6
8
  config.eager_load_namespaces << ActiveModel
7
9
 
10
+ config.active_model = ActiveSupport::OrderedOptions.new
11
+
8
12
  initializer "active_model.secure_password" do
9
13
  ActiveModel::SecurePassword.min_cost = Rails.env.test?
10
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
11
19
  end
12
20
  end
@@ -1,9 +1,11 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ActiveModel
2
4
  module SecurePassword
3
5
  extend ActiveSupport::Concern
4
6
 
5
- # BCrypt hash function can handle maximum 72 characters, and if we pass
6
- # password of length more than 72 characters it ignores extra characters.
7
+ # BCrypt hash function can handle maximum 72 bytes, and if we pass
8
+ # password of length more than 72 bytes it ignores extra characters.
7
9
  # Hence need to put a restriction on password length.
8
10
  MAX_PASSWORD_LENGTH_ALLOWED = 72
9
11
 
@@ -14,19 +16,20 @@ module ActiveModel
14
16
 
15
17
  module ClassMethods
16
18
  # Adds methods to set and authenticate against a BCrypt password.
17
- # 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.
18
21
  #
19
22
  # The following validations are added automatically:
20
23
  # * Password must be present on creation
21
- # * Password length should be less than or equal to 72 characters
22
- # * Confirmation of password (using a +password_confirmation+ attribute)
24
+ # * Password length should be less than or equal to 72 bytes
25
+ # * Confirmation of password (using a +XXX_confirmation+ attribute)
23
26
  #
24
- # If password confirmation validation is not needed, simply leave out the
25
- # 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
26
29
  # it). When this attribute has a +nil+ value, the validation will not be
27
30
  # triggered.
28
31
  #
29
- # For further customizability, it is possible to supress the default
32
+ # For further customizability, it is possible to suppress the default
30
33
  # validations by passing <tt>validations: false</tt> as an argument.
31
34
  #
32
35
  # Add bcrypt (~> 3.1.7) to Gemfile to use #has_secure_password:
@@ -35,35 +38,40 @@ module ActiveModel
35
38
  #
36
39
  # Example using Active Record (which automatically includes ActiveModel::SecurePassword):
37
40
  #
38
- # # Schema: User(name:string, password_digest:string)
41
+ # # Schema: User(name:string, password_digest:string, recovery_password_digest:string)
39
42
  # class User < ActiveRecord::Base
40
43
  # has_secure_password
44
+ # has_secure_password :recovery_password, validations: false
41
45
  # end
42
46
  #
43
47
  # user = User.new(name: 'david', password: '', password_confirmation: 'nomatch')
44
- # user.save # => false, password required
48
+ # user.save # => false, password required
45
49
  # user.password = 'mUc3m00RsqyRe'
46
- # user.save # => false, confirmation doesn't match
50
+ # user.save # => false, confirmation doesn't match
47
51
  # user.password_confirmation = 'mUc3m00RsqyRe'
48
- # user.save # => true
49
- # user.authenticate('notright') # => false
50
- # user.authenticate('mUc3m00RsqyRe') # => user
51
- # User.find_by(name: 'david').try(:authenticate, 'notright') # => false
52
- # User.find_by(name: 'david').try(:authenticate, 'mUc3m00RsqyRe') # => user
53
- 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)
54
62
  # Load bcrypt gem only when has_secure_password is used.
55
63
  # This is to avoid ActiveModel (and by extension the entire framework)
56
64
  # being dependent on a binary library.
57
65
  begin
58
- require 'bcrypt'
66
+ require "bcrypt"
59
67
  rescue LoadError
60
68
  $stderr.puts "You don't have bcrypt installed in your application. Please add it to your Gemfile and run bundle install"
61
69
  raise
62
70
  end
63
71
 
64
- include InstanceMethodsOnActivation
72
+ include InstanceMethodsOnActivation.new(attribute)
65
73
 
66
- if options.fetch(:validations, true)
74
+ if validations
67
75
  include ActiveModel::Validations
68
76
 
69
77
  # This ensures the model has a password by checking whether the password_digest
@@ -71,63 +79,49 @@ module ActiveModel
71
79
  # when there is an error, the message is added to the password attribute instead
72
80
  # so that the error message will make sense to the end-user.
73
81
  validate do |record|
74
- record.errors.add(:password, :blank) unless record.password_digest.present?
82
+ record.errors.add(attribute, :blank) unless record.public_send("#{attribute}_digest").present?
75
83
  end
76
84
 
77
- validates_length_of :password, maximum: ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED
78
- validates_confirmation_of :password, allow_blank: true
79
- end
80
-
81
- # This code is necessary as long as the protected_attributes gem is supported.
82
- if respond_to?(:attributes_protected_by_default)
83
- def self.attributes_protected_by_default #:nodoc:
84
- super + ['password_digest']
85
- end
85
+ validates_length_of attribute, maximum: ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED
86
+ validates_confirmation_of attribute, allow_blank: true
86
87
  end
87
88
  end
88
89
  end
89
90
 
90
- module InstanceMethodsOnActivation
91
- # Returns +self+ if the password is correct, otherwise +false+.
92
- #
93
- # class User < ActiveRecord::Base
94
- # has_secure_password validations: false
95
- # end
96
- #
97
- # user = User.new(name: 'david', password: 'mUc3m00RsqyRe')
98
- # user.save
99
- # user.authenticate('notright') # => false
100
- # user.authenticate('mUc3m00RsqyRe') # => user
101
- def authenticate(unencrypted_password)
102
- BCrypt::Password.new(password_digest) == unencrypted_password && self
103
- end
91
+ class InstanceMethodsOnActivation < Module
92
+ def initialize(attribute)
93
+ attr_reader attribute
104
94
 
105
- 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
106
104
 
107
- # Encrypts the password into the +password_digest+ attribute, only if the
108
- # new password is not empty.
109
- #
110
- # class User < ActiveRecord::Base
111
- # has_secure_password validations: false
112
- # end
113
- #
114
- # user = User.new
115
- # user.password = nil
116
- # user.password_digest # => nil
117
- # user.password = 'mUc3m00RsqyRe'
118
- # user.password_digest # => "$2a$10$4LEA7r4YmNHtvlAvHhsYAeZmk/xeUVtMTYqwIvYY76EW5GUqDiP4."
119
- def password=(unencrypted_password)
120
- if unencrypted_password.nil?
121
- self.password_digest = nil
122
- elsif !unencrypted_password.empty?
123
- @password = unencrypted_password
124
- cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost
125
- 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
126
122
  end
127
- end
128
123
 
129
- def password_confirmation=(unencrypted_password)
130
- @password_confirmation = unencrypted_password
124
+ alias_method :authenticate, :authenticate_password if attribute == :password
131
125
  end
132
126
  end
133
127
  end