Back when Rails 5 was released a new attributes API was introduced to us. It allowed us to take advantage of a type casting mechanism that had long been used internally in Rails.
Ever noticed how the parameters that are passed to a controller come in as strings but then once they are assigned to a model instance they are converted to dates, integers, decimals, etc.? That all happens through the ActiveModel::Types API. The team went one step further with the release - we can now also use this for ActiveModel classes, which is actually a much more common use case in my experience.
In both ActiveRecord and ActiveModel the process is the same for creating custom types and casting attributes using those types.
First we need to create a custom type, then we register the new type to ActiveRecord or ActiveModel, then we can use it when defining our attributes.
Creating a custom type
In Rails, it's common practice to work in UTC time. However, here in Thailand a lot of the APIs we work with expect us to accept values in GMT+7. To accommodate this we can create a new BangkokDateTimeType to cast values to the Bangkok timezone. ActiveModel already includes a DateTime so we can build on top of that and modify the cast method to cast into the correct timezone
class BangkokDateTimeType < ActiveModel::Type::DateTime
def cast(value)
return nil unless value.present?
value.in_time_zone('Bangkok')
end
end
Registering types with ActiveRecord and ActiveModel
Now we need to register this type to ActiveRecord and ActiveModel. We'll create an initializer to handle this. The first argument will be the symbol used to identify the type when we define our attributes.
Notice we didn't supply the timezone but Rails is now correctly casting to the Bangkok timezone. We can still pass the timezone and Rails will know how to handle this
We can create a DowncasedStringType to ensure emails are always downcased, or a ClampedPercentageType to ensure we only accept values between 0.0 and 100.0. We can also do more complex things like give structure to a jsonb column
class RewardDetailType < ActiveRecord::Type::Value
REWARD_DETAIL = Struct.new(:type, :value, :unit, keyword_init: true)
def type
:jsonb
end
def cast(value)
sanitized = sanitize_input(value)
REWARD_DETAIL.new(sanitized)
end
def deserialize(value)
decoded = ActiveSupport::JSON.decode(value)
sanitized = sanitize_input(decoded)
REWARD_DETAIL.new(sanitized)
end
def serialize(value)
ActiveSupport::JSON.encode(value)
end
private def sanitize_input(input)
input.slice(:type, :value, :unit)
end
end
This type seems to have a specific use case so registering it probably doesn't serve much purpose. We can tell the attribute to use this class instead of using the symbol lookup.
class Reward < ApplicationRecord
attribute :detail, RewardDetailType.new
end
Now we can pass in a hash or call a record from a database and then treat the json data like a struct, because it is a struct, e.g.
The attributes API is really powerful and often overlooked. You can cast, serialize, and deserialize values, and even use it to specify default values for an attribute. I highly recommend checking it out.
> ActiveSupport.on_load(:active_record) do
> ActiveRecord::Type.register(...)
> end
Otherwise active record is loaded earlier in the pipeline and that can be an issue for CI.
Where do you place your types? Is it in "app/types"?
Yeah I normally keep them in app/attribute_types