This blog is part of our Rails 5 series.

Rails 5 was a major release with a lot of new features like Action Cable, API Applications, etc. Active Record attribute API was also one of the features of Rails 5 release which did not receive much attention.

Active Record attributes API is used by Rails internally for a long time. In Rails 5 release, attributes API was made public and allowed support for custom types.

What is attribute API?

Attribute API converts the attribute value to an appropriate Ruby type. Here is how the syntax looks like.

attribute(name, cast_type, options)

The first argument is the name of the attribute and the second argument is the cast type. Cast type can be string, integer or custom type object.

# db/schema.rb

create_table :movie_tickets, force: true do |t|
  t.float :price
end

# without attribute API

class MovieTicket < ActiveRecord::Base
end

movie_ticket = MovieTicket.new(price: 145.40)
movie_ticket.save!

movie_ticket.price   # => Float(145.40)

# with attribute API

class MovieTicket < ActiveRecord::Base
  attribute :price, :integer
end

movie_ticket.price   # => 145

Before using attribute API, movie ticket price was a float value, but after applying attribute on price, the price value was typecast as integer.

The database still stores the price as float and this conversion happens only in Ruby land.

Now, we will typecast movie release_date from datetime to date type.

# db/schema.rb

create_table :movies, force: true do |t|
  t.datetime :release_date
end

class Movie < ActiveRecord::Base
  attribute :release_date, :date
end

movie.release_date # => Thu, 01 Mar 2018

We can also add default value for an attribute.

# db/schema.rb

create_table :movies, force: true do |t|
  t.string :license_number, :string
end

class Movie < ActiveRecord::Base
  attribute :license_number,
            :string,
            default: "IN00#{Date.current.strftime('%Y%m%d')}00#{rand(100)}"
end

# without attribute API with default value on license number

Movie.new.license_number  # => nil

# with attribute API with default value on license number

Movie.new.license_number  # => "IN00201805250068"

Custom Types

Let’s say we want the people to rate a movie in percentage. Traditionally, we would do something like this.

class MovieRating < ActiveRecord::Base

  TOTAL_STARS = 5

  before_save :convert_percent_rating_to_stars

  def convert_percent_rating_to_stars
    rating_in_percentage = value.gsub(/\%/, '').to_f

    self.rating = (rating_in_percentage * TOTAL_STARS) / 100
  end
end

With attributes API we can create a custom type which will be responsible to cast to percentage rating to number of stars.

We have to define the cast method in the custom type class which casts the given value to the expected output.

# db/schema.rb

create_table :movie_ratings, force: true do |t|
  t.integer :rating
end

# app/types/star_rating_type.rb

class StarRatingType < ActiveRecord::Type::Integer
  TOTAL_STARS = 5

  def cast(value)
    if value.present? && !value.kind_of?(Integer)
      rating_in_percentage = value.gsub(/\%/, '').to_i

      star_rating = (rating_in_percentage * TOTAL_STARS) / 100
      super(star_rating)
    else
      super
    end
  end
end

# config/initializers/types.rb

ActiveRecord::Type.register(:star_rating, StarRatingType)

# app/models/movie.rb

class MovieRating < ActiveRecord::Base
  attribute :rating, :star_rating
end

Querying

The attributes API also supports where clause. Query will be converted to SQL by calling serialize method on the type object.

class StarRatingType < ActiveRecord::Type::Integer
  TOTAL_STARS = 5

  def serialize(value)
    if value.present? && !value.kind_of?(Integer)
      rating_in_percentage = value.gsub(/\%/, '').to_i

      star_rating = (rating_in_percentage * TOTAL_STARS) / 100
      super(star_rating)
    else
      super
    end
  end
end


# Add new movie rating with rating as 25.6%.
# So the movie rating in star will be 1 of 5 stars.
movie_rating = MovieRating.new(rating: "25.6%")
movie_rating.save!

movie_rating.rating   # => 1

# Querying with rating in percentage 25.6%
MovieRating.where(rating: "25.6%")

# => #<ActiveRecord::Relation [#<MovieRating id: 1000, rating: 1 ... >]>