Active Record Association Advanced Topics

What are polymorphic associations? When do we need to create join tables? When do we use single table inheritence?

We will cover some more advanced topics with Active Record Associations in this post.

Note: This post assumes that you are already familiar with common Active Record associations. You can see this post for Active Record associations basics for a refresher.

Data modeling is a skill that is an art and a science. In order to get it right, we need to think deeply about the natural relationship between our models in the real world. Active Record associations provide many options to model our business domain. If our models do not neatly fit into the common associations, we can use some of the concepts below and manually define our relationships.

What are Polymorphic Associations

If we need a model to belong to more than one other model on a single association, we'd use polymorphic associations.

Let's see this with an example. With the models below, we have Employee and Product. Both of those can have many Picture. So we make polymorphic association called imageable.

class Picture < ApplicationRecord
  belongs_to :imageable, polymorphic: true

class Employee < ApplicationRecord
  has_many :pictures, as: :imageable

class Product < ApplicationRecord
  has_many :pictures, as: :imageable

The interesting part here is the belongs_to :imageable, Polymorphic: true. This implies that we will have an imagable_id on Picture that will a foreign key to either Employee or Product. In order to know which one we will also have an imagable_type on Picture.

The migration for Picture looks like this

class CreatePictures < ActiveRecord::Migration[7.0]
  def change
    create_table :pictures do |t|
      t.string  :name
      t.bigint  :imageable_id
      t.string  :imageable_type

    add_index :pictures, [:imageable_type, :imageable_id]

Another way to create the imageable_id and imageable_type is with t.references :imageable, polymorphic: true. I like the first one as it's more explicit and makes it clear that we will have two fields id and type.

Creating Join Tables

For has_and_belongs_to_many association, we need to create a join table in our migration. This join table should not have a primary key. This is important for things to work correctly. This join table does not represent a model. (Note: if we want a join table that can hold other fields, we use has_many :through association).

There are two ways to create a table without primary key id. We can use create_join_table in the migration or set id to false if using create_table like this

  def change
    create_table :authors_books, id: false do |t|
      t.bigint :author_id
      t.bigint :book_id

Bi-directional Associations and inverse_of

Active Record will automatically identify most bi-directional associations with standard names. By bi-directional association we mean having belong_to on one model and has_many on the "opposite" model, for example.

In case we decide to use non standard names though, we need to manually specify the "opposite" model. We can do this with inverse_of option.

With the below model, we are using writer instead of author so now @book.writer works to refer to the Author object, but @author doesn't 'know' about the relationship if we leave out the inserve_of on the has_many on Author below

class Author < ApplicationRecord
  has_many :books, inverse_of: 'writer'

class Book < ApplicationRecord
  belongs_to :writer, class_name: 'Author', foreign_key: 'author_id'

Association Callbacks

There are four callbacks that can specify methods to be called when an object is added for removed from a collection through the association. before_add, after_add, before_remove, after_remove.

We specify the callback as an option to the association declaration.

class LibraryPatron < ApplicationRecord
  has_many :books, before_add: :checkout_limit

  def checkout_limit(book)
    throw(:abort) if limit_reached?

With that in place, a library patron will only be allowed to add books if their checkout limit is not reached.

These are similar to before_save callback that causes something to happen before an object is saved. The association callbacks only happen when an object is added or removed through the association collection.

Single Table Inheritance

Sometimes we want to share fields and behavior between different models but also have specific behavior for each and separate controllers too. With Single Table Inheritance (STI), we do this by storing the related models in a single table but have a type column in that table that differentiates them.

For a concrete example, let's say we want to represent different types of vehicles that all have color and price fields in common. We can create a vehicle model

$ rails generate model vehicle type:string color:string price:decimal

Then we can generate a car which is a type of vehicle like this

$ generate model car --parent=Vehicle

That will generate a model that look like this

class Car < Vehicle

And when we create a Car object, it will be stored in the vehicles table with the type column set to "Car"

Car.create(color: 'red' price: 10000)

with this SQL

INSERT INTO "vehicles" ("type", "color", "price") VALUES ('Car', 'Red', 10000)

The above explanation and examples give us a better idea of all the ways in which we can define Active Record associations in our applications.

Show Comments