Introduction to Rails 5 Attributes

Shortly after the tenth anniversary of Ruby on Rails 1.0, Rails 5.0 Beta has been announced. While the main character of this release was without a doubt ActionCable , other really great features have made their debut.

Types of Changes

One feature that particularly stood out is the introduction of ActiveRecord Attributes . This feature allows a developer to assert a specific type for a given attribute and an optional default value. It is not strict type validation (which I have a very strong affinity for), but it does define explicit type coercion that can be very useful.

Given an example application that deals with Transactions , a single table and model might exist:

class CreateTransactions < ActiveRecord :: Migration def change create_table :transactions do | t | t . integer :user_id t . string :item_name t . integer :quantity t . string :success t . decimal :price t . timestamps ( null: false ) end end end class Transaction < ActiveRecord :: Base end

This structure, like most in the wild, could have been created before all edge cases were thought through. For some reason, success is a String instead of a Boolean . While this problem might seem trivial, imagine a system where hundreds of millions of transactions exist.

In such a system the entire column might not be able to change without incurring downtime. This is a perfect example problem that Attributes can help remedy.

Starting simple, a single line can be added to the Transaction class definition to coerce success to a boolean :

class Transaction < ActiveRecord :: Base attribute :success , :boolean end

Using the same logic that ActiveRecord uses for database column coercion, we can see attributes in action!

transaction = Transaction . new ( success: 'yes' ) transaction . success # => true transaction = Transaction . new ( success: 'f' ) transaction . success # => false transaction = Transaction . new ( success: 0 ) transaction . success # => false

Just like that, the schema has been improved. Aside from raw SQL update statements, this code now prevents strings like "maybe" from littering the success column of the transactions table.

Before Attributes , a callback or custom setter method would have been the preferred way to address this problem. However, with this new built in solution for mismatched database column types, old one-off or ad-hoc solutions can be replaced with this new standard.

The Friendly Type

With such a clean DSL, it is obvious that a lot of time and thought went into the design of Attributes . Being able to specify how a model should interact with the persistence layer is a powerful tool.

The full list of supported types that Attributes provides can be found deep in the source code for ActiveRecord or right here:

#Supported Types :big_integer :binary :boolean :date :date_time :decimal :float :integer :string :text :time

Each of these types has their own place and value in a system. Their purpose is simple: to make the experience between developer and framework fluid.

The Attribute feature is not restricted to database columns. If an attribute is only used during the life cycle of an object, it too can benefit from type coercion.

For instance, if a confirmed_at attribute had a useful purpose for a Transaction , but the format of it could vary, the Attribute module can step in and keep things clean.

class Transaction attribute :confirmed_at , :date_time end

Without adding a new column or defining an attr_accessor for the confirmed_at attribute, the :date_time coercion works perfectly:

transaction = Transaction . new transaction . confirmed_at = '2015-12-12 03:00' transaction . confirmed_at # => Sat, 12 Dec 2015 03:00:00 UTC +00:00 transaction = Transaction . new transaction . confirmed_at = '2015/12/12' transaction . confirmed_at # => Sat, 12 Dec 2015 00:00:00 UTC +00:00

Since most Ruby on Rails applications deal with strings via form encoded data, it is easy to see just how valuable the Attribute module can be.

Out of the Box Typing

A fantastic detail about Attributes is that type coercion is not limited to only “supported” types. Any object that adheres to a proper contract may be used.

To help illustrate this feature, we can assume that all prices in the database have been changed to only deal with cents. This helps remove some complexities with floating point math oddities and prevents nefarious Office Space/Superman III bugs from creeping up.

Given a MoneyType object:

class MoneyType < ActiveRecord :: Type :: Integer def type_cast ( value ) if value . include? ( '$' ) price_in_dollars = value . gsub ( /\$/ , '' ). to_f price_in_dollars * 100 else value . to_i end end end

An important takeaway here is the inheritance and type_cast method definition. Both elements are necessary to create a custom type that the Attributes module can use effectively.

To use this new type, an initialized object is passed to the attribute method:

class Transaction < ActiveRecord :: Base attribute :price , MoneyType . new end

Then, if all keystrokes were done with style and grace, the effects should look something like:

Transaction . where ( price: '$10.00' ) # => SELECT * FROM transactions WHERE price = 1000

The observed behaviour here is very interesting. A string representing a money amount can be coerced to an Integer value representing the same data. Instead of the MoneyType ’s type_cast method being extracted and used many times in controller or other model methods, it can be centralized to one specific spot.

Like the previous example, this solution is not the only way to achieve the desired result; however, it is unique enough to inspire new ways of thinking about these problems.

Type Validation

Avoiding hidden type coercion was a driving force when I created the validates_type gem. With Attributes , that coercion is still there but can be explicitly specified in a model’s definition. While this is a great step forward, I still find value in rejecting data of the wrong type. As a previous example can illustrate: why does the string "yes" map to the boolean value true ? It does make some sense but "yes" is not a boolean.