ruby-on-railsruby-on-rails-7ruby-on-rails-8

Rails counter cache for has_one :through on Rails 7 and 8


I am struggling to get a counter cache working on a has_one :through and running into some weird behaviour. I want to have a counter_cache on the Company model that caches the number of associated Location (through the BusinessUnit model).

Objects:

class Company < ApplicationRecord
  has_many :business_units, class_name: :BusinessUnit, dependent: :destroy
  has_many :locations, through: :business_units, dependent: :destroy, counter_cache: :locations_count
end

class BusinessUnit < ApplicationRecord
  belongs_to :company, counter_cache: :locations_count
  has_many :locations, dependent: :destroy
end

class Location < ApplicationRecord
  belongs_to :business_unit
  has_one :company, through: :business_unit
end

Schema:

ActiveRecord::Schema[7.1].define(version: 2025_04_03_203549) do
  enable_extension "plpgsql"

  create_table "business_units", force: :cascade do |t|
    t.string "name"
    t.bigint "company_id", null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["company_id"], name: "index_business_units_on_company_id"
  end

  create_table "companies", force: :cascade do |t|
    t.string "name"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.integer "locations_count", default: 0, null: false
  end

  create_table "locations", force: :cascade do |t|
    t.string "name"
    t.bigint "business_unit_id", null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["business_unit_id"], name: "index_locations_on_business_unit_id"
  end

  add_foreign_key "business_units", "companies"
  add_foreign_key "locations", "business_units"
end

My understanding based on these SO answers (1, 2) is that this should work. It's not well documented and a bit counterintuitive how Rails automatically knows to refer to the locations count, but indeed it does seem to have some rails magic and calling Company.reset_counters(1, :locations) correctly sets the counter to match the number of locations.

HOWEVER, the counter doesn't update automatically (up or down) when locations are added or removed, and if a BusinessUnit with N locations is updated and assigned to another company the counter only increments/decrements by 1 (instead of by the expected N locations). This behaviour is present on both Rails 8.0.1 and 7.1.3 (which I accidentally used to build my MRE).

Is there something obvious I'm missing that can make the counter_cache work correctly?


Solution

  • This won't actually work.

    The counter_cache option only really works on belongs_to associations* and will update the column on the model on the other side of it which is derived from the name of the association. On the has_many side it's just used if the name of the counter cache column doesn't match the automatically derived name when using the cache.

    What you want to do here is pretty far from the narrow use case the counter_cache option is designed for.

    If you want to combine this database design with a cached count of the indirect associations you'll have to look into other options like the counter culture gem or incrementing the column with a association callback or explicitly from the controller.