Skip to content

Commit

Permalink
Port BeforeTypeCast to Active Model
Browse files Browse the repository at this point in the history
This commit ports `ActiveRecord::AttributeMethods::BeforeTypeCast` to
`ActiveModel::BeforeTypeCast` and includes it in `ActiveModel::Attributes`.
Thus, classes that include `ActiveModel::Attributes` will now
automatically define methods such as `*_before_type_cast`, just as
Active Record models do.

The `ActiveRecord::AttributeMethods::BeforeTypeCast` module is kept for
backward compatibility, but it now merely includes
`ActiveModel::BeforeTypeCast`.

Co-authored-by: Petrik <petrik@deheus.net>
  • Loading branch information
jonathanhefner and p8 committed Nov 4, 2023
1 parent 7d525d3 commit cfb72c9
Show file tree
Hide file tree
Showing 8 changed files with 240 additions and 105 deletions.
21 changes: 21 additions & 0 deletions 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.
Expand Down
1 change: 1 addition & 0 deletions activemodel/lib/active_model.rb
Expand Up @@ -39,6 +39,7 @@ module ActiveModel
autoload :AttributeAssignment
autoload :AttributeMethods
autoload :AttributeRegistration
autoload :BeforeTypeCast
autoload :BlockValidator, "active_model/validator"
autoload :Callbacks
autoload :Conversion
Expand Down
8 changes: 4 additions & 4 deletions activemodel/lib/active_model/attribute_methods.rb
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions activemodel/lib/active_model/attribute_registration.rb
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions activemodel/lib/active_model/attributes.rb
Expand Up @@ -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"
Expand Down
128 changes: 128 additions & 0 deletions 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 <tt>*_before_type_cast</tt> attribute methods.
def attribute_before_type_cast(attr_name)
@attributes[attr_name].value_before_type_cast
end

# Dispatch target for <tt>*_for_database</tt> attribute methods.
def attribute_for_database(attr_name)
@attributes[attr_name].value_for_database
end

# Dispatch target for <tt>*_came_from_user?</tt> attribute methods.
def attribute_came_from_user?(attr_name)
@attributes[attr_name].came_from_user?
end
end
end
79 changes: 79 additions & 0 deletions 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

0 comments on commit cfb72c9

Please sign in to comment.