merging scopes with STI models

August 30, 2012 JT Archie

Introduction

On a client project recently, we ran into a domain problem that didn’t fit into the ActiveRecord standard conventions. The following is the thought process taken to get to our solution, so it gets detailed in some areas.

ActiveRecord has a great feature called Single Table Inheritance. It allows a model to have multiple types while using a single database table for the storage. Those type abstractions can each have their own validations, override base functionality, and specific abstraction functionality.

If your model has ever been littered with case statements checking if a User is a guest, admin, etc., you should take a look at STI.

Models

The project had a based model that had many types and each abstraction had a scope of .active that defined what it meant to be active for that type.

class Person < ActiveRecord::Base; end

class FireFighter < Person
  def self.active
    where(has_helmet: true)
  end
end

class PoliceOfficer < Person
  def self.active
    where(has_squad_car: true)
  end
end

Problem

We needed to create an API endpoint that returned all active Person instances. This would require us to iterate through each child of Person and get all its current active members. Since we have Person model let’s give it a concept of .active that incorporates every active member of society in our domain.

Solution

We can extend Person to return an array of each active FireFighter and PoliceOfficer.

class Person < ActiveRecord::Base
  def self.active
    FireFighter.active.all + PoliceOfficer.active.all
  end
end

One problem we have with this implementation is every time we add a new abstraction of Person we have to add to .active. Luckily, ActiveRecord STI comes with support for looking up a parent’s .descendants.

class Person < ActiveRecord::Base
  def self.active
    active_people = descendants.map do |descendant|
      d.active.all
    end.flatten
  end
end

This is pretty powerful. We can add Astronaut and any active astronauts will automatically be in Person.active array. This implementation will help satisfy our API endpoint requirements, but it does break useful ActiveRecord patterns.

Advance Solution

WARNING: Continue at your own risk. If you are content with the solution above stop, but if you want to see what can be done with Arel continue.

What if we want to chain scopes or extend the .active with pagination for our API? We cannot do this because easily because we are currently returning a Ruby array instead of an ActiveRecord::Relation. How can we modify .active to be an actual scope?

You might be thinking, ActiveRecord comes with the ability to merge scopes between models. Unfortunately, it does not work very well when merging scopes with STI models.

We ended using Arel (known for not being well documented) within our model. Each ActiveRecord::Relation is actually just an object with holding on to Arel values for different parts of an SQL statement — joins, froms, selects, etc. We are able to get the conditions for WHERE clause by looking at the ActiveRecord::Relation where_values.

class Person < ActiveRecord::Base
  def self.active
    conditions = descendants.map do |d|
      d.active.where_values.reduce(:and)
    end.reduce(:or)

    where(conditions)
  end
end

Our implementation takes the where_values from the .active scope from each descendant and does an SQL OR on them. ActiveRecord::Relation can take

# somewhere in a Rails console
> Person.active.to_sql
=> SELECT "people".* FROM "people"  WHERE (
      ("people"."has_helmet" = 't' AND "people"."type" = "FireFighter")
        OR
      ("people"."has_squad_car" = 't' AND "people"."type" = "PoliceOfficer")
    )

What does give us? We can now use Person.active as a normal scope, which allows us to append any conditions on to it.

> Person.active.where(created_at: 2.days.ago..1.day.ago).order(:created_at)
=> []
> Person.active.limit(10)
=> []

About the Author

Biography

Previous
Delve Into the Deep Blue Sea of Oceanic Data with Marinexplore
Delve Into the Deep Blue Sea of Oceanic Data with Marinexplore

You probably learned in grade school that most of the Earth is covered in water; the ocean alone covers 71%...

Next
Tracker Screencast: Project Tabs
Tracker Screencast: Project Tabs

I seem to discover new things in Tracker all the time. After looking over the shoulder of someone on the d...