Haseeb Annadamban

Let's Start Using Rails Attributes Api

ยท 500 words ยท 3 minutes to read

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.

Your Feedback Matters!

I'd love to hear your thoughts on this. Connect with me on LinkedIn Or Twitter