diff --git a/activemodel/CHANGELOG.md b/activemodel/CHANGELOG.md index 253ec94bf9c91..ee3ee809d1408 100644 --- a/activemodel/CHANGELOG.md +++ b/activemodel/CHANGELOG.md @@ -1,3 +1,24 @@ +* Port the `BeforeTypeCast` module to Active Model. Classes that include + `ActiveModel::Attributes` will now automatically define methods such as + `*_before_type_cast`, `*_for_database`, etc. These methods behave the same + for Active Model as they do for Active Record. + + ```ruby + class MyModel + include ActiveModel::Attributes + + attribute :my_attribute, :integer + end + + m = MyModel.new + m.my_attribute = "123" + m.my_attribute # => 123 + m.my_attribute_before_type_cast # => "123" + m.read_attribute_before_type_cast(:my_attribute) # => "123" + ``` + + *Jonathan Hefner* + * Port the `type_for_attribute` method to Active Model. Classes that include `ActiveModel::Attributes` will now provide this method. This method behaves the same for Active Model as it does for Active Record. diff --git a/activemodel/lib/active_model.rb b/activemodel/lib/active_model.rb index cda8f412d8c35..049704f6a9ab7 100644 --- a/activemodel/lib/active_model.rb +++ b/activemodel/lib/active_model.rb @@ -39,6 +39,7 @@ module ActiveModel autoload :AttributeAssignment autoload :AttributeMethods autoload :AttributeRegistration + autoload :BeforeTypeCast autoload :BlockValidator, "active_model/validator" autoload :Callbacks autoload :Conversion diff --git a/activemodel/lib/active_model/attribute_methods.rb b/activemodel/lib/active_model/attribute_methods.rb index b9582566cdd22..cf64937f08b14 100644 --- a/activemodel/lib/active_model/attribute_methods.rb +++ b/activemodel/lib/active_model/attribute_methods.rb @@ -376,6 +376,10 @@ def aliases_by_attribute_name # :nodoc: @aliases_by_attribute_name ||= Hash.new { |h, k| h[k] = [] } end + def resolve_attribute_name(name) # :nodoc: + attribute_aliases.fetch(super, &:itself) + end + private def inherited(base) # :nodoc: super @@ -384,10 +388,6 @@ def inherited(base) # :nodoc: end end - def resolve_attribute_name(name) - attribute_aliases.fetch(super, &:itself) - end - def generated_attribute_methods @generated_attribute_methods ||= Module.new.tap { |mod| include mod } end diff --git a/activemodel/lib/active_model/attribute_registration.rb b/activemodel/lib/active_model/attribute_registration.rb index 6574c01c9beaa..420d86e1ba973 100644 --- a/activemodel/lib/active_model/attribute_registration.rb +++ b/activemodel/lib/active_model/attribute_registration.rb @@ -55,6 +55,10 @@ def type_for_attribute(attribute_name, &block) end end + def resolve_attribute_name(name) # :nodoc: + name.to_s + end + private PendingType = Struct.new(:name, :type) do # :nodoc: def apply_to(attribute_set) @@ -103,10 +107,6 @@ def reset_default_attributes! @attribute_types = nil end - def resolve_attribute_name(name) - name.to_s - end - def resolve_type_name(name, **options) Type.lookup(name, **options) end diff --git a/activemodel/lib/active_model/attributes.rb b/activemodel/lib/active_model/attributes.rb index 0c21b57201d03..d8a123dffee7e 100644 --- a/activemodel/lib/active_model/attributes.rb +++ b/activemodel/lib/active_model/attributes.rb @@ -31,6 +31,7 @@ module Attributes extend ActiveSupport::Concern include ActiveModel::AttributeRegistration include ActiveModel::AttributeMethods + include ActiveModel::BeforeTypeCast included do attribute_method_suffix "=", parameters: "value" diff --git a/activemodel/lib/active_model/before_type_cast.rb b/activemodel/lib/active_model/before_type_cast.rb new file mode 100644 index 0000000000000..cb64c71037691 --- /dev/null +++ b/activemodel/lib/active_model/before_type_cast.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +module ActiveModel + # This module provides a way to read the value of attributes before type + # casting and deserialization. It uses ActiveModel::AttributeMethods to define + # attribute methods with the following suffixes: + # + # * +_before_type_cast+ + # * +_for_database+ + # * +_came_from_user?+ + # + # ==== Examples + # + # class Task + # include ActiveModel::Attributes + # + # attribute :completed_at, :datetime + # end + # + # task = Task.new + # task.completed_at # => nil + # task.completed_at_before_type_cast # => nil + # task.completed_at_for_database # => nil + # task.completed_at_came_from_user? # => false + # + # task.completed_at = "1999-12-31T23:59:59-0500" + # task.completed_at # => 1999-12-31 23:59:59 -0500 + # task.completed_at_before_type_cast # => "1999-12-31T23:59:59-0500" + # task.completed_at_for_database # => 2000-01-01 04:59:59 UTC + # task.completed_at_came_from_user? # => true + # + module BeforeTypeCast + extend ActiveSupport::Concern + + included do + attribute_method_suffix "_before_type_cast", "_for_database", "_came_from_user?", parameters: false + end + + # Returns the value of the specified attribute before type casting and + # deserialization. + # + # class Task + # include ActiveModel::Attributes + # + # attribute :completed_at, :datetime + # end + # + # task = Task.new + # task.completed_at = "1999-12-31T23:59:59-0500" + # + # task.completed_at # => 1999-12-31 23:59:59 -0500 + # task.read_attribute_before_type_cast("completed_at") # => "1999-12-31T23:59:59-0500" + # + def read_attribute_before_type_cast(attribute_name) + attribute_before_type_cast(self.class.resolve_attribute_name(attribute_name)) + end + + # Returns the value of the specified attribute after serialization. + # + # class Task + # include ActiveModel::Attributes + # + # attribute :completed_at, :datetime + # end + # + # task = Task.new + # task.completed_at = "1999-12-31T23:59:59-0500" + # + # task.completed_at # => 1999-12-31 23:59:59 -0500 + # task.read_attribute_for_database("completed_at") # => 2000-01-01 04:59:59 UTC + # + def read_attribute_for_database(attribute_name) + attribute_for_database(self.class.resolve_attribute_name(attribute_name)) + end + + # Returns a Hash of attributes before type casting and deserialization. + # + # class Task + # include ActiveModel::Attributes + # + # attribute :completed_at, :datetime + # end + # + # task = Task.new + # task.completed_at = "1999-12-31T23:59:59-0500" + # + # task.attributes # => {"completed_at"=>1999-12-31 23:59:59 -0500} + # task.attributes_before_type_cast # => {"completed_at"=>"1999-12-31T23:59:59-0500"} + # + def attributes_before_type_cast + @attributes.values_before_type_cast + end + + # Returns a Hash of attributes for persisting. + # + # class Task + # include ActiveModel::Attributes + # + # attribute :completed_at, :datetime + # end + # + # task = Task.new + # task.completed_at = "1999-12-31T23:59:59-0500" + # + # task.attributes # => {"completed_at"=>1999-12-31 23:59:59 -0500} + # task.attributes_for_database # => {"completed_at"=>2000-01-01 04:59:59 UTC} + # + def attributes_for_database + @attributes.values_for_database + end + + private + # Dispatch target for *_before_type_cast attribute methods. + def attribute_before_type_cast(attr_name) + @attributes[attr_name].value_before_type_cast + end + + # Dispatch target for *_for_database attribute methods. + def attribute_for_database(attr_name) + @attributes[attr_name].value_for_database + end + + # Dispatch target for *_came_from_user? attribute methods. + def attribute_came_from_user?(attr_name) + @attributes[attr_name].came_from_user? + end + end +end diff --git a/activemodel/test/cases/before_type_cast_test.rb b/activemodel/test/cases/before_type_cast_test.rb new file mode 100644 index 0000000000000..ecdcc6be1e26a --- /dev/null +++ b/activemodel/test/cases/before_type_cast_test.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require "cases/helper" + +class BeforeTypeCastTest < ActiveModel::TestCase + class Developer + include ActiveModel::Attributes + + attribute :name, :string + attribute :salary, :integer + attribute :active, :boolean + alias_attribute :compensation, :salary + + def initialize(attributes = {}) + super() + attributes.each { |name, value| public_send("#{name}=", value) } + end + end + + setup do + @before_type_cast = { name: 1234, salary: "56789", active: "0" } + @after_type_cast = { name: "1234", salary: 56789, active: false } + @developer = Developer.new(@before_type_cast) + end + + test "#read_attribute_before_type_cast" do + assert_equal @before_type_cast[:salary], @developer.read_attribute_before_type_cast(:salary) + end + + test "#read_attribute_before_type_cast with aliased attribute" do + assert_equal @before_type_cast[:salary], @developer.read_attribute_before_type_cast(:compensation) + end + + test "#read_attribute_for_database" do + assert_equal @after_type_cast[:salary], @developer.read_attribute_for_database(:salary) + end + + test "#read_attribute_for_database with aliased attribute" do + assert_equal @after_type_cast[:salary], @developer.read_attribute_for_database(:compensation) + end + + test "#attributes_before_type_cast" do + assert_equal @before_type_cast.transform_keys(&:to_s), @developer.attributes_before_type_cast + end + + test "#attributes_before_type_cast with missing attributes" do + assert_equal @before_type_cast.to_h { |key, value| [key.to_s, nil] }, Developer.new.attributes_before_type_cast + end + + test "#attributes_for_database" do + assert_equal @after_type_cast.transform_keys(&:to_s), @developer.attributes_for_database + end + + test "#*_before_type_cast" do + assert_equal @before_type_cast[:salary], @developer.salary_before_type_cast + end + + test "#*_before_type_cast with aliased attribute" do + assert_equal @before_type_cast[:salary], @developer.compensation_before_type_cast + end + + test "#*_for_database" do + assert_equal @after_type_cast[:salary], @developer.salary_for_database + end + + test "#*_for_database with aliased attribute" do + assert_equal @after_type_cast[:salary], @developer.compensation_for_database + end + + test "#*_came_from_user?" do + assert_predicate @developer, :salary_came_from_user? + assert_not_predicate Developer.new, :salary_came_from_user? + end + + test "#*_came_from_user? with aliased attribute" do + assert_predicate @developer, :compensation_came_from_user? + assert_not_predicate Developer.new, :compensation_came_from_user? + end +end diff --git a/activerecord/lib/active_record/attribute_methods/before_type_cast.rb b/activerecord/lib/active_record/attribute_methods/before_type_cast.rb index e4d562ad62276..b16b0c0e87d72 100644 --- a/activerecord/lib/active_record/attribute_methods/before_type_cast.rb +++ b/activerecord/lib/active_record/attribute_methods/before_type_cast.rb @@ -2,105 +2,10 @@ module ActiveRecord module AttributeMethods - # = Active Record Attribute Methods Before Type Cast - # - # ActiveRecord::AttributeMethods::BeforeTypeCast provides a way to - # read the value of the attributes before typecasting and deserialization. - # - # class Task < ActiveRecord::Base - # end - # - # task = Task.new(id: '1', completed_on: '2012-10-21') - # task.id # => 1 - # task.completed_on # => Sun, 21 Oct 2012 - # - # task.attributes_before_type_cast - # # => {"id"=>"1", "completed_on"=>"2012-10-21", ... } - # task.read_attribute_before_type_cast('id') # => "1" - # task.read_attribute_before_type_cast('completed_on') # => "2012-10-21" - # - # In addition to #read_attribute_before_type_cast and #attributes_before_type_cast, - # it declares a method for all attributes with the *_before_type_cast - # suffix. - # - # task.id_before_type_cast # => "1" - # task.completed_on_before_type_cast # => "2012-10-21" + # See ActiveModel::BeforeTypeCast. module BeforeTypeCast extend ActiveSupport::Concern - - included do - attribute_method_suffix "_before_type_cast", "_for_database", parameters: false - attribute_method_suffix "_came_from_user?", parameters: false - end - - # Returns the value of the attribute identified by +attr_name+ before - # typecasting and deserialization. - # - # class Task < ActiveRecord::Base - # end - # - # task = Task.new(id: '1', completed_on: '2012-10-21') - # task.read_attribute('id') # => 1 - # task.read_attribute_before_type_cast('id') # => '1' - # task.read_attribute('completed_on') # => Sun, 21 Oct 2012 - # task.read_attribute_before_type_cast('completed_on') # => "2012-10-21" - # task.read_attribute_before_type_cast(:completed_on) # => "2012-10-21" - def read_attribute_before_type_cast(attr_name) - name = attr_name.to_s - name = self.class.attribute_aliases[name] || name - - attribute_before_type_cast(name) - end - - # Returns the value of the attribute identified by +attr_name+ after - # serialization. - # - # class Book < ActiveRecord::Base - # enum status: { draft: 1, published: 2 } - # end - # - # book = Book.new(status: "published") - # book.read_attribute(:status) # => "published" - # book.read_attribute_for_database(:status) # => 2 - def read_attribute_for_database(attr_name) - name = attr_name.to_s - name = self.class.attribute_aliases[name] || name - - attribute_for_database(name) - end - - # Returns a hash of attributes before typecasting and deserialization. - # - # class Task < ActiveRecord::Base - # end - # - # task = Task.new(title: nil, is_done: true, completed_on: '2012-10-21') - # task.attributes - # # => {"id"=>nil, "title"=>nil, "is_done"=>true, "completed_on"=>Sun, 21 Oct 2012, "created_at"=>nil, "updated_at"=>nil} - # task.attributes_before_type_cast - # # => {"id"=>nil, "title"=>nil, "is_done"=>true, "completed_on"=>"2012-10-21", "created_at"=>nil, "updated_at"=>nil} - def attributes_before_type_cast - @attributes.values_before_type_cast - end - - # Returns a hash of attributes for assignment to the database. - def attributes_for_database - @attributes.values_for_database - end - - private - # Dispatch target for *_before_type_cast attribute methods. - def attribute_before_type_cast(attr_name) - @attributes[attr_name].value_before_type_cast - end - - def attribute_for_database(attr_name) - @attributes[attr_name].value_for_database - end - - def attribute_came_from_user?(attr_name) - @attributes[attr_name].came_from_user? - end + include ActiveModel::BeforeTypeCast end end end