Abacus

Introduction

Today I was attempting to use Rails’ counter cache feature to save on expensive queries. My model’s association is a has_many :through. I ran into issues on my first attempt but eventually came across the answer after beating my head against a wall for quite some time.

TL;DR: If you use a custom counter_cache column on your belongs_to, duplicate that on the has_many associations as well.

Models

Let’s work with a standard, easy to understand, has_many :through models:

class Physician < ApplicationRecord
  has_many :appointments
  has_many :patients, through: :appointments
end

class Appointment < ApplicationRecord
  belongs_to :physician
  belongs_to :patient
end

class Patient < ApplicationRecord
  has_many :appointments
  has_many :physicians, through: :appointments
end

Counter Cache, Attempt #1

Now let’s implement counter cache on these models with custom column names. On the belongs_to side of the association, we’ll set up our counter caches for physicians and patients.

class Appointment < ApplicationRecord
  belongs_to :physician, counter_cache: :patients_size
  belongs_to :patient, counter_cache: :physicians_size
end

Migration

Now, we need to add these counter cache columns with a migration:

class AddRenderingCountsToCampaign < ActiveRecord::Migration[6.0]
  def change
    add_column :physicians, :patients_size, :integer, default: 0, null: false
    add_column :patients, :physicians_size, :integer, default: 0, null: false

    reversible do |dir|
      dir.up do
          Physician.find_each { |p| Physician.reset_counters(p, :patients) }
          Patient.find_each { |p| Patient.reset_counters(p.id, :physicians) }
      end
    end
  end
end

This migration will set up our database with these new columns and set values for existing Physicians and Patients via reset_counters.

Be sure to run rails db:migrate

Test It

After that, we can now test it in the Rails console.

1] pry(main)> p = Physician.last
  Physician Load (4.5ms)  ...
=> #<Physician:0x00007fd7aa4acb60. ..>
[2] pry(main)> p.patients.size
   (5.6ms)  SELECT COUNT(*) FROM ...
=> 1

Hmm, from my understanding, the size method should be using our counter cache, not issuing a SQL COUNT query in the database. What’s going on here?

Digging into the Documentation

So, it’s not working. Querying the web, it’s difficult to find help with this issue. That is until you dig into the Rails docs, specifically the has_many method

Scrolling through the options for has_many, you’ll come across :counter_cache and it says: “This option can be used to configure a custom named :counter_cache. You only need this option, when you customized the name of your :counter_cache on the #belongs_to association.”

Eureka! We customized the name of our :counter_cache, so let’s give this a try!

Attempt #2

Let’s revisit the models and make this modification.

class Physician < ApplicationRecord
  has_many :appointments, counter_cache: :patients_size
  has_many :patients, through: :appointments, counter_cache: :patients_size
end

class Appointment < ApplicationRecord
  belongs_to :physician, counter_cache: :patients_size
  belongs_to :patient, counter_cache: :physicians_size
end

class Patient < ApplicationRecord
  has_many :appointments, counter_cache: :physicians_size
  has_many :physicians, through: :appointments, counter_cache: :physicians_size
end

Now, we’ll hop back into the Rails console and test it out:

1] pry(main)> p = Physician.last
  Physician Load (4.5ms)  ...
=> #<Physician:0x00007fd7aa4acb60. ..>
[2] pry(main)> p.patients.size
  => 1

Yay! Now it works!

Note that I added the :counter_cache option on both has_may associations. If not, and you tried to do p.appointments.size, it would query the DB again.

Conclusion

This was a tough nut to crack. Again, most blog posts on the web don’t outline this gotcha. The answer is in the Rails docs but somewhat hidden. I hope this post helps another dev save some time and sanity.