aboutsummaryrefslogtreecommitdiffstats
path: root/activemodel
diff options
context:
space:
mode:
Diffstat (limited to 'activemodel')
-rw-r--r--activemodel/CHANGELOG.md51
-rw-r--r--activemodel/lib/active_model.rb1
-rw-r--r--activemodel/lib/active_model/attribute_assignment.rb63
-rw-r--r--activemodel/lib/active_model/errors.rb58
-rw-r--r--activemodel/lib/active_model/model.rb7
-rw-r--r--activemodel/lib/active_model/naming.rb1
-rw-r--r--activemodel/test/cases/attribute_assignment_test.rb107
-rw-r--r--activemodel/test/cases/errors_test.rb42
-rw-r--r--activemodel/test/cases/model_test.rb4
9 files changed, 308 insertions, 26 deletions
diff --git a/activemodel/CHANGELOG.md b/activemodel/CHANGELOG.md
index fb4b972282..77386e5e41 100644
--- a/activemodel/CHANGELOG.md
+++ b/activemodel/CHANGELOG.md
@@ -1,7 +1,56 @@
+* Assigning an unknown attribute key to an `ActiveModel` instance during initialization
+ will now raise `ActiveModel::AttributeAssignment::UnknownAttributeError` instead of
+ `NoMethodError`
+
+ ```ruby
+ User.new(foo: 'some value')
+ # => ActiveModel::AttributeAssignment::UnknownAttributeError: unknown attribute 'foo' for User.
+ ```
+
+ *Eugene Gilburg*
+
+* Extracted `ActiveRecord::AttributeAssignment` to `ActiveModel::AttributeAssignment`
+ allowing to use it for any object as an includable module
+
+ ``` ruby
+ class Cat
+ include ActiveModel::AttributeAssignment
+ attr_accessor :name, :status
+ end
+
+ cat = Cat.new
+ cat.assign_attributes(name: "Gorby", status: "yawning")
+ cat.name # => 'Gorby'
+ cat.status => 'yawning'
+ cat.assign_attributes(status: "sleeping")
+ cat.name # => 'Gorby'
+ cat.status => 'sleeping'
+ ```
+
+ *Bogdan Gusiev*
+
+* Add `ActiveModel::Errors#details`
+
+ To be able to return type of used validator, one can now call `details`
+ on Errors instance:
+
+ ```ruby
+ class User < ActiveRecord::Base
+ validates :name, presence: true
+ end
+ ```
+
+ ```ruby
+ user = User.new; user.valid?; user.errors.details
+ => {name: [{error: :blank}]}
+ ```
+
+ *Wojciech Wnętrzak*
+
* Change validates_acceptance_of to accept true by default.
The default for validates_acceptance_of is now "1" and true.
- In the past, only "1" was the default and you were required to add
+ In the past, only "1" was the default and you were required to add
accept: true.
* Remove deprecated `ActiveModel::Dirty#reset_#{attribute}` and
diff --git a/activemodel/lib/active_model.rb b/activemodel/lib/active_model.rb
index 46d60db756..58fe08cc11 100644
--- a/activemodel/lib/active_model.rb
+++ b/activemodel/lib/active_model.rb
@@ -28,6 +28,7 @@ require 'active_model/version'
module ActiveModel
extend ActiveSupport::Autoload
+ autoload :AttributeAssignment
autoload :AttributeMethods
autoload :BlockValidator, 'active_model/validator'
autoload :Callbacks
diff --git a/activemodel/lib/active_model/attribute_assignment.rb b/activemodel/lib/active_model/attribute_assignment.rb
new file mode 100644
index 0000000000..356421476c
--- /dev/null
+++ b/activemodel/lib/active_model/attribute_assignment.rb
@@ -0,0 +1,63 @@
+require 'active_support/core_ext/hash/keys'
+
+module ActiveModel
+ module AttributeAssignment
+ include ActiveModel::ForbiddenAttributesProtection
+
+ # Allows you to set all the attributes by passing in a hash of attributes with
+ # keys matching the attribute names.
+ #
+ # If the passed hash responds to <tt>permitted?</tt> method and the return value
+ # of this method is +false+ an <tt>ActiveModel::ForbiddenAttributesError</tt>
+ # exception is raised.
+ #
+ # class Cat
+ # include ActiveModel::AttributeAssignment
+ # attr_accessor :name, :status
+ # end
+ #
+ # cat = Cat.new
+ # cat.assign_attributes(name: "Gorby", status: "yawning")
+ # cat.name # => 'Gorby'
+ # cat.status => 'yawning'
+ # cat.assign_attributes(status: "sleeping")
+ # cat.name # => 'Gorby'
+ # cat.status => 'sleeping'
+ def assign_attributes(new_attributes)
+ if !new_attributes.respond_to?(:stringify_keys)
+ raise ArgumentError, "When assigning attributes, you must pass a hash as an argument."
+ end
+ return if new_attributes.blank?
+
+ attributes = new_attributes.stringify_keys
+ _assign_attributes(sanitize_for_mass_assignment(attributes))
+ end
+
+ private
+
+ def _assign_attributes(attributes)
+ attributes.each do |k, v|
+ _assign_attribute(k, v)
+ end
+ end
+
+ def _assign_attribute(k, v)
+ if respond_to?("#{k}=")
+ public_send("#{k}=", v)
+ else
+ raise UnknownAttributeError.new(self, k)
+ end
+ end
+
+ # Raised when unknown attributes are supplied via mass assignment.
+ class UnknownAttributeError < NoMethodError
+ attr_reader :record, :attribute
+
+ def initialize(record, attribute)
+ @record = record
+ @attribute = attribute
+ super("unknown attribute '#{attribute}' for #{@record.class}.")
+ end
+ end
+ end
+end
diff --git a/activemodel/lib/active_model/errors.rb b/activemodel/lib/active_model/errors.rb
index 477edbd120..a809c72ccd 100644
--- a/activemodel/lib/active_model/errors.rb
+++ b/activemodel/lib/active_model/errors.rb
@@ -2,6 +2,7 @@
require 'active_support/core_ext/array/conversions'
require 'active_support/core_ext/string/inflections'
+require 'active_support/core_ext/object/deep_dup'
module ActiveModel
# == Active \Model \Errors
@@ -23,7 +24,7 @@ module ActiveModel
# attr_reader :errors
#
# def validate!
- # errors.add(:name, "cannot be nil") if name.nil?
+ # errors.add(:name, :blank, message: "cannot be nil") if name.nil?
# end
#
# # The following methods are needed to be minimally implemented
@@ -58,8 +59,9 @@ module ActiveModel
include Enumerable
CALLBACKS_OPTIONS = [:if, :unless, :on, :allow_nil, :allow_blank, :strict]
+ MESSAGE_OPTIONS = [:message]
- attr_reader :messages
+ attr_reader :messages, :details
# Pass in the instance of the object that is using the errors object.
#
@@ -71,10 +73,12 @@ module ActiveModel
def initialize(base)
@base = base
@messages = {}
+ @details = Hash.new { |details, attribute| details[attribute] = [] }
end
def initialize_dup(other) # :nodoc:
@messages = other.messages.dup
+ @details = other.details.deep_dup
super
end
@@ -85,6 +89,7 @@ module ActiveModel
# person.errors.full_messages # => []
def clear
messages.clear
+ details.clear
end
# Returns +true+ if the error messages include an error for the given key
@@ -126,6 +131,7 @@ module ActiveModel
# person.errors.get(:name) # => nil
def delete(key)
messages.delete(key)
+ details.delete(key)
end
# When passed a symbol or a name of a method, returns an array of errors
@@ -149,12 +155,12 @@ module ActiveModel
# Yields the attribute and the error for that attribute. If the attribute
# has more than one error message, yields once for each error message.
#
- # person.errors.add(:name, "can't be blank")
+ # person.errors.add(:name, :blank, message: "can't be blank")
# person.errors.each do |attribute, error|
# # Will yield :name and "can't be blank"
# end
#
- # person.errors.add(:name, "must be specified")
+ # person.errors.add(:name, :not_specified, message: "must be specified")
# person.errors.each do |attribute, error|
# # Will yield :name and "can't be blank"
# # then yield :name and "must be specified"
@@ -167,9 +173,9 @@ module ActiveModel
# Returns the number of error messages.
#
- # person.errors.add(:name, "can't be blank")
+ # person.errors.add(:name, :blank, message: "can't be blank")
# person.errors.size # => 1
- # person.errors.add(:name, "must be specified")
+ # person.errors.add(:name, :not_specified, message: "must be specified")
# person.errors.size # => 2
def size
values.flatten.size
@@ -193,8 +199,8 @@ module ActiveModel
# Returns an array of error messages, with the attribute name included.
#
- # person.errors.add(:name, "can't be blank")
- # person.errors.add(:name, "must be specified")
+ # person.errors.add(:name, :blank, message: "can't be blank")
+ # person.errors.add(:name, :not_specified, message: "must be specified")
# person.errors.to_a # => ["name can't be blank", "name must be specified"]
def to_a
full_messages
@@ -202,9 +208,9 @@ module ActiveModel
# Returns the number of error messages.
#
- # person.errors.add(:name, "can't be blank")
+ # person.errors.add(:name, :blank, message: "can't be blank")
# person.errors.count # => 1
- # person.errors.add(:name, "must be specified")
+ # person.errors.add(:name, :not_specified, message: "must be specified")
# person.errors.count # => 2
def count
to_a.size
@@ -223,8 +229,8 @@ module ActiveModel
# Returns an xml formatted representation of the Errors hash.
#
- # person.errors.add(:name, "can't be blank")
- # person.errors.add(:name, "must be specified")
+ # person.errors.add(:name, :blank, message: "can't be blank")
+ # person.errors.add(:name, :not_specified, message: "must be specified")
# person.errors.to_xml
# # =>
# # <?xml version=\"1.0\" encoding=\"UTF-8\"?>
@@ -261,17 +267,20 @@ module ActiveModel
end
end
- # Adds +message+ to the error messages on +attribute+. More than one error
- # can be added to the same +attribute+. If no +message+ is supplied,
- # <tt>:invalid</tt> is assumed.
+ # Adds +message+ to the error messages and used validator type to +details+ on +attribute+.
+ # More than one error can be added to the same +attribute+.
+ # If no +message+ is supplied, <tt>:invalid</tt> is assumed.
#
# person.errors.add(:name)
# # => ["is invalid"]
- # person.errors.add(:name, 'must be implemented')
+ # person.errors.add(:name, :not_implemented, message: "must be implemented")
# # => ["is invalid", "must be implemented"]
#
# person.errors.messages
- # # => {:name=>["must be implemented", "is invalid"]}
+ # # => {:name=>["is invalid", "must be implemented"]}
+ #
+ # person.errors.details
+ # # => {:name=>[{error: :not_implemented}, {error: :invalid}]}
#
# If +message+ is a symbol, it will be translated using the appropriate
# scope (see +generate_message+).
@@ -293,16 +302,22 @@ module ActiveModel
# +attribute+ should be set to <tt>:base</tt> if the error is not
# directly associated with a single attribute.
#
- # person.errors.add(:base, "either name or email must be present")
+ # person.errors.add(:base, :name_or_email_blank,
+ # message: "either name or email must be present")
# person.errors.messages
# # => {:base=>["either name or email must be present"]}
+ # person.errors.details
+ # # => {:base=>[{error: :name_or_email_blank}]}
def add(attribute, message = :invalid, options = {})
+ message = message.call if message.respond_to?(:call)
+ detail = normalize_detail(attribute, message, options)
message = normalize_message(attribute, message, options)
if exception = options[:strict]
exception = ActiveModel::StrictValidationFailed if exception == true
raise exception, full_message(attribute, message)
end
+ details[attribute.to_sym] << detail
self[attribute] << message
end
@@ -339,6 +354,7 @@ module ActiveModel
# person.errors.add :name, :blank
# person.errors.added? :name, :blank # => true
def added?(attribute, message = :invalid, options = {})
+ message = message.call if message.respond_to?(:call)
message = normalize_message(attribute, message, options)
self[attribute].include? message
end
@@ -447,12 +463,14 @@ module ActiveModel
case message
when Symbol
generate_message(attribute, message, options.except(*CALLBACKS_OPTIONS))
- when Proc
- message.call
else
message
end
end
+
+ def normalize_detail(attribute, message, options)
+ { error: message }.merge(options.except(*CALLBACKS_OPTIONS + MESSAGE_OPTIONS))
+ end
end
# Raised when a validation cannot be corrected by end users and are considered
diff --git a/activemodel/lib/active_model/model.rb b/activemodel/lib/active_model/model.rb
index d51d6ddcc9..dac8d549a7 100644
--- a/activemodel/lib/active_model/model.rb
+++ b/activemodel/lib/active_model/model.rb
@@ -57,6 +57,7 @@ module ActiveModel
# (see below).
module Model
extend ActiveSupport::Concern
+ include ActiveModel::AttributeAssignment
include ActiveModel::Validations
include ActiveModel::Conversion
@@ -75,10 +76,8 @@ module ActiveModel
# person = Person.new(name: 'bob', age: '18')
# person.name # => "bob"
# person.age # => "18"
- def initialize(params={})
- params.each do |attr, value|
- self.public_send("#{attr}=", value)
- end if params
+ def initialize(attributes={})
+ assign_attributes(attributes) if attributes
super()
end
diff --git a/activemodel/lib/active_model/naming.rb b/activemodel/lib/active_model/naming.rb
index ada1f9a4f3..2bc3eeaa19 100644
--- a/activemodel/lib/active_model/naming.rb
+++ b/activemodel/lib/active_model/naming.rb
@@ -1,5 +1,6 @@
require 'active_support/core_ext/hash/except'
require 'active_support/core_ext/module/introspection'
+require 'active_support/core_ext/module/remove_method'
module ActiveModel
class Name
diff --git a/activemodel/test/cases/attribute_assignment_test.rb b/activemodel/test/cases/attribute_assignment_test.rb
new file mode 100644
index 0000000000..402caf21f7
--- /dev/null
+++ b/activemodel/test/cases/attribute_assignment_test.rb
@@ -0,0 +1,107 @@
+require "cases/helper"
+require "active_support/hash_with_indifferent_access"
+
+class AttributeAssignmentTest < ActiveModel::TestCase
+ class Model
+ include ActiveModel::AttributeAssignment
+
+ attr_accessor :name, :description
+
+ def initialize(attributes = {})
+ assign_attributes(attributes)
+ end
+
+ def broken_attribute=(value)
+ raise ErrorFromAttributeWriter
+ end
+
+ protected
+
+ attr_writer :metadata
+ end
+
+ class ErrorFromAttributeWriter < StandardError
+ end
+
+ class ProtectedParams < ActiveSupport::HashWithIndifferentAccess
+ def permit!
+ @permitted = true
+ end
+
+ def permitted?
+ @permitted ||= false
+ end
+
+ def dup
+ super.tap do |duplicate|
+ duplicate.instance_variable_set :@permitted, permitted?
+ end
+ end
+ end
+
+ test "simple assignment" do
+ model = Model.new
+
+ model.assign_attributes(name: "hello", description: "world")
+ assert_equal "hello", model.name
+ assert_equal "world", model.description
+ end
+
+ test "assign non-existing attribute" do
+ model = Model.new
+ error = assert_raises(ActiveModel::AttributeAssignment::UnknownAttributeError) do
+ model.assign_attributes(hz: 1)
+ end
+
+ assert_equal model, error.record
+ assert_equal "hz", error.attribute
+ end
+
+ test "assign private attribute" do
+ model = Model.new
+ assert_raises(ActiveModel::AttributeAssignment::UnknownAttributeError) do
+ model.assign_attributes(metadata: { a: 1 })
+ end
+ end
+
+ test "does not swallow errors raised in an attribute writer" do
+ assert_raises(ErrorFromAttributeWriter) do
+ Model.new(broken_attribute: 1)
+ end
+ end
+
+ test "an ArgumentError is raised if a non-hash-like obejct is passed" do
+ assert_raises(ArgumentError) do
+ Model.new(1)
+ end
+ end
+
+ test "forbidden attributes cannot be used for mass assignment" do
+ params = ProtectedParams.new(name: "Guille", description: "m")
+
+ assert_raises(ActiveModel::ForbiddenAttributesError) do
+ Model.new(params)
+ end
+ end
+
+ test "permitted attributes can be used for mass assignment" do
+ params = ProtectedParams.new(name: "Guille", description: "desc")
+ params.permit!
+ model = Model.new(params)
+
+ assert_equal "Guille", model.name
+ assert_equal "desc", model.description
+ end
+
+ test "regular hash should still be used for mass assignment" do
+ model = Model.new(name: "Guille", description: "m")
+
+ assert_equal "Guille", model.name
+ assert_equal "m", model.description
+ end
+
+ test "assigning no attributes should not raise, even if the hash is un-permitted" do
+ model = Model.new
+ assert_nil model.assign_attributes(ProtectedParams.new({}))
+ end
+end
diff --git a/activemodel/test/cases/errors_test.rb b/activemodel/test/cases/errors_test.rb
index efedd9055f..17dbb817d0 100644
--- a/activemodel/test/cases/errors_test.rb
+++ b/activemodel/test/cases/errors_test.rb
@@ -323,4 +323,46 @@ class ErrorsTest < ActiveModel::TestCase
person.errors.expects(:generate_message).with(:name, :blank, { message: 'custom' })
person.errors.add_on_blank :name, message: 'custom'
end
+
+ test "details returns added error detail" do
+ person = Person.new
+ person.errors.add(:name, :invalid)
+ assert_equal({ name: [{ error: :invalid }] }, person.errors.details)
+ end
+
+ test "details returns added error detail with custom option" do
+ person = Person.new
+ person.errors.add(:name, :greater_than, count: 5)
+ assert_equal({ name: [{ error: :greater_than, count: 5 }] }, person.errors.details)
+ end
+
+ test "details do not include message option" do
+ person = Person.new
+ person.errors.add(:name, :invalid, message: "is bad")
+ assert_equal({ name: [{ error: :invalid }] }, person.errors.details)
+ end
+
+ test "dup duplicates details" do
+ errors = ActiveModel::Errors.new(Person.new)
+ errors.add(:name, :invalid)
+ errors_dup = errors.dup
+ errors_dup.add(:name, :taken)
+ assert_not_equal errors_dup.details, errors.details
+ end
+
+ test "delete removes details on given attribute" do
+ errors = ActiveModel::Errors.new(Person.new)
+ errors.add(:name, :invalid)
+ errors.delete(:name)
+ assert_empty errors.details[:name]
+ end
+
+ test "clear removes details" do
+ person = Person.new
+ person.errors.add(:name, :invalid)
+
+ assert_equal 1, person.errors.details.count
+ person.errors.clear
+ assert person.errors.details.empty?
+ end
end
diff --git a/activemodel/test/cases/model_test.rb b/activemodel/test/cases/model_test.rb
index ee0fa26546..9a8d873ec9 100644
--- a/activemodel/test/cases/model_test.rb
+++ b/activemodel/test/cases/model_test.rb
@@ -70,6 +70,8 @@ class ModelTest < ActiveModel::TestCase
end
def test_mixin_initializer_when_args_dont_exist
- assert_raises(NoMethodError) { SimpleModel.new(hello: 'world') }
+ assert_raises(ActiveModel::AttributeAssignment::UnknownAttributeError) do
+ SimpleModel.new(hello: 'world')
+ end
end
end