When building applications in Ruby on Rails, We store data in Plain Old Ruby Object (called PORO) value objects. This is a common pattern, We store data directly in service objects using attr_accessor
. But in some cases we might have to cast data from string. For this, we do have to cast those attributes.
Traditional Approach ๐
class Product
attr_accessor :name, :price, :released_at, :in_stock
def initialize(attributes = {})
@name = attributes[:name]
@price = parse_price(attributes[:price] || '0')
@released_at = parse_datetime(attributes[:released_at])
@in_stock = parse_boolean(attributes[:in_stock])
end
private
def parse_price(value)
BigDecimal(value.to_s) rescue nil
end
def parse_datetime(value)
DateTime.parse(value.to_s) rescue nil
end
def parse_boolean(value)
return true if value == true || value =~ /^(true|t|yes|y|1)$/i
return false if value == false || value.nil? || value =~ /^(false|f|no|n|0)$/i
nil
end
end
In this example, We parse the values of decimal
, boolean
and datetime
types. This code is neat. But it will difficult to read if there are other methods which handles real logic. This is a nice place to make use of built in attributes in Rails Attributes API.
Using Rails Attributes API ๐
class Product
include ActiveModel::Model
include ActiveModel::Attributes
attribute :name, :string
attribute :price, :decimal, default: "0"
attribute :released_at, :datetime
attribute :in_stock, :boolean
end
# And you can still use it like,
product = Product.new(
name: "Widget",
price: "9.99",
released_at: "2024-07-27 10:00:00",
in_stock: "1"
)
puts product.name # => "Widget"
puts product.price # => 9.99 (BigDecimal)
puts product.released_at # => 2024-07-27 10:00:00 UTC (DateTime)
puts product.in_stock # => true (Boolean)
As you can see, using the Rails attributes API simplifies the code. Reduced boilerplate and better readability. Let me explain, In this example we used rails attributes API to parse the values. The attribute
method defines an attribute. The first parameter is the name of the attribute and second parameter is it’s type. Here we are making use of built in types like :string
, :decimal
and :datetime
.
What if my code requires custom parsing logic that is not available in rails?
Custom Types ๐
Here is a custom type implementation for Rails attributes API. This is to store compressed text
class CompressedTextType < ActiveRecord::Type::String
def serialize(value)
value.present? ? Zlib::Deflate.deflate(value) : nil
end
def deserialize(value)
value.present? ? Zlib::Inflate.inflate(value) : nil
end
end
We need to register the type in an initializer
# Initializer to register the custom type
ActiveRecord::Type.register(:compressed_text, CompressedTextType)
Then we can use it in our class
attribute :content, :compressed_text
This can be reused this in both models and value objects. The model will start using this type instead of the default type if attribute
value is set
# Usage in a Value object
class Product
include ActiveModel::Model
include ActiveModel::Attributes
attribute :name, :string
attribute :price, :decimal, default: "0"
attribute :released_at, :datetime
attribute :in_stock, :boolean
attribute :content, :compressed_text
end
# Usage in a model
class Article < ApplicationRecord
attribute :content, :compressed_text
end
Conclusion ๐
Rails attributes API is a very under used but powerful convenience available in Rails. Whether you’re building form objects, service objects, or any other custom classes, this API can help you ensure data consistency and also type safety.