Karol Galanciak - Ruby on Rails and Ember.js consultant

Introduction to ActiveRecord and ActiveModel Attributes API

Rails 5.0 is without a doubt a great release with plenty of useful changes and additions. The most notable change was probably ActionCable - the layer responsible for integrating your app with websockets. However, there were also other additions that could bring some substantial improvements to your Rails apps, but were a bit outshined by bigger changes. One of such features is Attributes API.

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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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
1
2
3
4
5
6
7
8
9
10
11
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
1
2
3
4
5
6
7
8
9
10
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:

app/types/price_type.rb
1
2
3
4
5
6
7
8
9
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
config/initializers/types.rb
1
ActiveRecord::Type.register(:price, Price)

Let’s use Attributes API for price attribute:

app/models/reservation.rb
1
2
3
4
5
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
1
2
3
4
5
6
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
1
2
3
4
5
6
7
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
1
2
3
4
5
6
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:

app/types/price_type.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
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.

1
2
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.

What About ActiveModel?

So far I’ve discussed only the ActiveRecord Attributes API, but the title clearly mentions ActiveModel part, so what about it? There is a bad news and good news.

The bad news is that it is not yet supported in Rails core, but most likely it is going to be the part of ActiveModel eventually.

The good news is that you can use it today, even though it’s not a part of Rails! I’ve released ActiveModelAttributes gem which provides Attributes API for ActiveModel and it works in a very similar way to ActiveRecord Attributes.

Just define your ActiveModel model, include ActiveModel::Model and ActiveModelAttributes modules and define attributes and their types using attribute class method:

app/models/my_awesome_model.rb
1
2
3
4
5
6
7
class MyAwesomeModel
  include ActiveModel::Model
  include ActiveModelAttributes

  attribute :description, :string, default: "default description"
  attribute :start_date, :date, default: -> { Date.new(2016, 1, 1) }
end

You can also add your custom types. Just create a class inheriting from ActiveModel::Type::Value or already existing type, e.g. ActiveModel::Type::Integer, define cast method and register the new type:

app/types/money_type.rb
1
2
3
4
5
6
7
8
9
class MoneyType < ActiveModel::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
config/initializers/types.rb
1
ActiveModel::Type.register(:money, MoneyType)
app/models/my_awesome_model.rb
1
2
3
4
5
6
class MyAwesomeModel
  include ActiveModel::Model
  include ActiveModelAttributes

  attribute :price, :money
end

And that’s it! Check the docs, start using it today and enjoy ;)

Wrapping up

ActiveRecord Attributes API is defintely a great feature introduced in Rails 5.0. Even though it is not yet supported in ActiveModel in Rails core, ActiveModelAttributes can be easily added to your Rails apps to provide almost the same functionality.

Comments