Jorge Manrubia

May 7, 2022

Rails attributes: an essential API you can probably ignore

vardan-papikyan-DnXqvmS0eXM-unsplash.jpg


A few years ago, Rails 5 introduced an API for declaring Active Record attributes. It lets you define typed attributes at the model level and control via types how to cast values assigned to them.

Active Record internally uses this API profusely. Active Record models automatically declare these attributes for you by inferring the types from the database. Did you ever wonder how Rails converts date columns to the local time zone when reading them through active record models?

Person.type_for_attribute(:updated_at).class
=> ActiveRecord::AttributeMethods::TimeZoneConversion::TimeZoneConverter

The answer is a type in charge of doing the casting for these attributes.

Same reason why these three queries trigger the same query:

>> Posting.where(seen: false).to_sql
=> "SELECT `postings`.* FROM `postings` WHERE `postings`.`seen` = FALSE"
>> Posting.where(seen: "F").to_sql
=> "SELECT `postings`.* FROM `postings` WHERE `postings`.`seen` = FALSE"
>> Posting.where(seen: 0).to_sql
=> "SELECT `postings`.* FROM `postings` WHERE `postings`.`seen` = FALSE"

>> Posting.type_for_attribute(:seen).class
=> ActiveModel::Type::Boolean

Again, there is a type that serializes those common boolean conventions to a proper boolean value. The same casting system used for persisting database values serves when querying those attributes.

Active Record also uses this API internally in several features, such as serialized attributes and enums. The fact that it works in queries was a major factor in using it in Active Record encryption: you could query encrypted attributes seamlessly for free! I've been quite happy with the decision. I recently could leverage its support for default attributes to support encrypting default database values.

As a Rails user, the most common way of using this API is via Active Model. When dealing with form data, sometimes you need Active Record-like conversion for things like dates and booleans with regular objects (e.g., for form objects). This API gets you covered:

class Person
  include ActiveModel::Attributes
  
  attribute :date_of_birth, :date
  attribute :employed, :boolean
end

>> person = Person.new
>> person.date_of_birth = "1980-12-12"
>> person.date_of_birth
=> Fri, 12 Dec 1980
>> person.date_of_birth.class
=> Date

>> p.employed = "0"
>> p.employed?
=> false

When it comes to Active Record, you usually don't care about this API. You can use higher-level abstractions such as serialized attributes or stored attributes for custom serialization needs. But you can still find edge cases where this API comes in handy. For example, I recently wanted to reuse a boolean column to define an enum without modifying the huge underlying table. Rails would infer a boolean type for the TINYINT MySQL column, which wouldn't play well with the enum. The solution? Override the type to an integer and define the enum normally:

attribute :some_boolean_attribute, :integer
enum some_boolean_attribute: { one: 1, two: 2, three: 3 }

This is a great example of how good APIs come to life. Active Record had the notion of column types way before this existed. The API was iterated, extracted, made an official part of Active Record and, eventually, of Active Model. Internally, it allowed building new features on top of it, and refactoring other existing features to use it. And externally, any Rails programmer can use it now, even if, thanks to great abstractions, you can forget it exists.

---
jorgemanrubia.com
Photo by Vardan Papikyan on Unsplash