ActiveRecord Attributes And Defaults - The Old Way

Imagine that you are in a vacation rental industry and you are adding a new model for handling reservations for rentals, let’s call it Reservation. To keep it simple for the purpose of this example, let’s assume that we need start_date and end_date date fields for handling the duration of the reservations and price field, which is pretty useful unless you are developing an app for a charity organization ;). Let’s say we want to provide some defaults for the start_date and end_date attributes to be 1 day from now and 8 days from know accordingly when initializing a new instance of Reservation and the price should be converted to integer, so in fact it is going to be price in cents, and the expected format of the input is going to look like "$1000.12". How could we handle it inside ActiveRecord models?

For default values, one option would be to add after_initialize callbacks which would assign the given defaults unless the values were already set in the initializer. For price we can simply override the attribute writer which is Reservation#price= method. We would most likely end up with something looking like this:

#app/models/reservation.rb
class Reservation < ApplicationRecord
  after_initialize :set_default_start_date
  after_initialize :set_default_end_date

  def price=(value)
    return super(0) if !value.to_s.include?('$')

    price_in_dollars = value.gsub(/\$/, '').to_d
    super(price_in_dollars * 100)
  end

  private

  def set_default_start_date
    self.start_date = 1.day.from_now if start_date.blank?
  end

  def set_default_end_date
    self.end_date = 8.days.from_now if end_date.blank?
  end
end

Well, the above code works, but it can get repetitive across many models and doesn’t read that well, would be much better to handle it with more of a declarative approach. But is there any built-in solution for that problem in ActiveRecord?

Then answer is yes! Time to meet your new friend in Rails world: ActiveRecord Attributes API.

ActiveRecord Attributes And Defaults - The New Way - Attributes API

Since Rails 5.0 we can use awesome Attributes API in our models. Just declare the name of the attribute with attribute class method, its type and provide optional default (either a raw value or a lambda). The great thing is that you are not limited only to attributes backed by database, you can use it for virtual attributes as well!

For our Reservation model, we could apply the following refactoring with Attributes API:

#app/models/reservation.rb
class Reservation < ApplicationRecord
  attribute :start_date, :date, default: -> { 1.day.from_now }
  attribute :end_date, :date, default: -> { 8.days.from_now }

  def price=(val)
    return super(0) if !value.to_s.include?('$')

    price_in_dollars = value.gsub(/\$/, '').to_d
    super(price_in_dollars * 100)
  end
end

Looks much cleaner now! Let’s see how it works:

#app/models/reservation.rb
2.3.1 :001 > reservation = Reservation.new
 => #<Reservation id: nil, start_date: "2016-12-03", end_date: "2016-12-10", price: nil, created_at: nil, updated_at: nil>
2.3.1 :002 > reservation.start_date
 => Sat, 03 Dec 2016
2.3.1 :003 > reservation.end_date
 => Sat, 10 Dec 2016
2.3.1 :004 > reservation = Reservation.new(start_date: 3.days.from_now)
 => #<Reservation id: nil, start_date: "2016-12-05", end_date: "2016-12-10", price: nil, created_at: nil, updated_at: nil>
2.3.1 :005 > reservation.start_date
 => Mon, 05 Dec 2016

That’s exactly what we needed. What about our conversion for price? As we can specify the type for given attribute, we may expect that it would be possible to define our own types. Turns out it is possible and quite simple actually. Just create a class inheriting from ActiveRecord::Type::Value or already existing type, e.g. ActiveRecord::Type::Integer, define cast method and register the new type. In our use case let’s register a new price type:

class PriceType < ActiveRecord::Type::Integer
  def cast(value)
    return super if value.kind_of?(Numeric)
    return super if !value.to_s.include?('$')

    price_in_dollars = BigDecimal.new(value.gsub(/\$/, ''))
    super(price_in_dollars * 100)
  end
end

ActiveRecord::Type.register(:price, Price)

Let’s use Attributes API for price attribute:

#app/models/reservation.rb
class Reservation < ApplicationRecord
  attribute :start_date, :date, default: -> { 1.day.from_now }
  attribute :end_date, :date, default: -> { 8.days.from_now }
  attribute :price, :price
end

And let’s test if it indeed works as expected:

#app/models/reservation.rb
2.3.1 :001 > reservation = Reservation.new
 => #<Reservation id: nil, start_date: "2016-12-03", end_date: "2016-12-10", price: nil, created_at: nil, updated_at: nil>
2.3.1 :002 > reservation.price = "$100.12"
 => "$100.12"
2.3.1 :003 > reservation.price
 => 10012

Nice! Much cleaner and easy to reuse.

Attributes API comes also with some other features, you could e.g. provide array or range option and work with arrays and ranges for given type:

#app/models/reservation.rb
class Reservation < ApplicationRecord
  attribute :start_date, :date, default: -> { 1.day.from_now }
  attribute :end_date, :date, default: -> { 8.days.from_now }
  attribute :price, :money
  attribute :virtual_array, :integer, array: true
  attribute :virtual_range, :date, range: true
end

.
#app/models/reservation.rb
2.3.1 :001 > reservation = Reservation.new(virtual_array: ["1.0", "2"], virtual_range: "[2016-01-01,2017-01-1]")
=> #<Reservation id: nil, start_date: "2016-12-03", end_date: "2016-12-10", price: nil, created_at: nil, updated_at: nil>
2.3.1 :002 > reservation.virtual_array
=> [1, 2]
2.3.1 :003 > reservation.virtual_range
=> Fri, 01 Jan 2016..Sun, 01 Jan 2017

Attributes API is already looking great, but it’s not the end of the story. You can use your custom types for querying a database, you just need to define serialize method for your own types:

class PriceType < ActiveRecord::Type::Integer
  def cast(value)
    return super if value.kind_of?(Numeric)
    return super if !value.to_s.include?('$')

    price_in_dollars = BigDecimal.new(value.gsub(/\$/, ''))
    super(price_in_dollars * 100)
  end

  def serialize(value)
    cast(value)
  end
end

That way we could simply give prices in original format as arguments and they are going to be converted to price in cents before performing a query.

Reservation.where(price: "$100.12")
 => Reservation Load (0.3ms)  SELECT "reservations".* FROM "reservations" WHERE "reservations"."price" = $1  [["price", 10012]]

As expected, the price used for query was the one after serialization.

If you want to check the list of built-in types or learn more, check the official docs.