Rails Counter Cache Gotcha
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 bothhas_may
associations. If not, and you tried to dop.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.