Continuing my deep dive into Rails features [1], I recently read about the Attributes API and more particularly about how it can be used with custom types.
The attribute
API
The attributes
method allows you to define an attribute with a type on a model. This type can be a completely custom.
In our example, we have spaceships that are defined by their category. Those categories are both immutable and interchangeable, and are good candidates to be transformed into value objects.
Our current model looks like this:
# app/models/ship.rb
class Ship < ApplicationRecord
validates :name, presence: true
validates :category, presence: true
validates :category, inclusion: { in: Ship::Category::VALID_CATEGORIES }
end
And our Ship::Category
value type looks like this:
# app/models/ship/category.rb
class Ship::Category
VALID_CATEGORIES = %w(
shuttle supply_carrier troop_carrier war_ship
).freeze
def initialize(category)
raise "invalid category '#{category.inspect}'" unless category.in?(VALID_CATEGORIES)
@raw_category = category
end
def to_s
raw_category
end
# Conform to comparable.
def <=>(other)
VALID_CATEGORIES.index(to_s) <=> VALID_CATEGORIES.index(other.to_s)
end
private
attr_reader :raw_category
end
Now, let's update our model to retrieve our value object:
# app/models/ship.rb
class Ship < ApplicationRecord
# ...
def category
@category ||= Ship::Category.new(self[:category])
end
end
While this does what we want, this can be improved by creating a custom type.
Creating a custom type
We create our custom type by inheriting from ActiveRecord::Type::Value
and overriding the necessary methods:
type
is the type of our object when saved in the database. In our case this will be:string
.cast
is the method called by ActiveRecord when setting the attribute in the model. In our case, we will instantiate our value object.deserialize
converts the value from the database to our value object. By default it callscast
.serialize
converts our value object to a type that the database understands. In our case, we'll send back the string containing the raw category.
For our type it looks like this:
# app/types/ship_category.rb
class CategoryType < ActiveRecord::Type::Value
def type
:string
end
def cast(value)
Ship::Category.new(value)
end
def deserialize(value)
cast(value)
end
def serialize(value)
value.to_s
end
end
Registering our type
Now that our type is created, we need to register it so ActiveRecord knows about it:
# config/initializers/types.rb
ActiveRecord::Type.register(:ship_category, CategoryType)
You will need to restart your Rails server or re-register your type every time you update it.
Using it in our model
Finally, we can use it in our model:
class Ship < ApplicationRecord
attribute :category, :ship_category
validates :name, presence: true
validates :category, presence: true
end
At this point, you might be wondering: Why would I do this? Personally, I feel like it is cleaner to let Rails handle the instantiation of objects instead of the usual memoization-with-instance-variables dance.
Furthermore, it allows you to add additional features to your model without having pollute the model class. In our case, we allow our ships to be compared based on their categories by implementing Comparable in our Category.
However, there are ways to make this particular use case fall down. In our example above, we limit the category to the values defined in VALID_CATEGORIES
. This means that creating a row in our database with a value that isn't valid will make our application raise when trying to instantiate the row into a Ship
object:
INSERT INTO ships(id, name, category, created_at, updated_at)
VALUES (10, 'USS Enterprise', 'interstellar_liner', NOW(), NOW());
enterprise = Ship.find(10)
# => Error: invalid category 'interstellar_liner'
For the purpose of this blog post, I chose a fairly simple example to present the attributes API. To achieve a similar result, you could also define the categories as a ActiveRecord Enum, as a Postgres Enum[2] or even have them in their own table and have a Postgres association between a Ship and its category, that is backed by a foreign key to achieve integrity of your data.
Thanks to Bachir Çaoui and Baicheng Yu for reviewing draft versions of this post.
Further reading
- Martin Fowler's post on Value Objects.
- A post on using the Attributes API with JSONB.
Footnotes
See my previous post about Namespaced Rails validators. ↩︎
However, this would require you to change your schema format to
:sql
or to override Rails's Schema dumper to handle Postgres Enums. ↩︎