Rails 3 counter_cache with has_many :through
After an evening of getting knee deep in the Rails core debugging and issue with counter_cache on a has_many :through, it seems this has been reported on lighthouse, and a fix has been applied in edge.
The issue is this.
In my scenario, I have a Drink, which has many Ingredient through a DrinkIngredient model. I also have a counter_cache called ingredients_count to keep track of how many ingredients each drink has.
class Drink < ActiveRecord::Base
has_many drink_ingredients, :dependent => :destroy
has_many ingredients, :through => :drink_ingredients
end
class Ingredient < ActiveRecord::Base
has_many drink_ingredients, :dependent => :destroy
has_many drinks, :through => :drink_ingredients
end
class DrinkIngredient < ActiveRecord::Base
belongs_to :drink, :counter_cache => :ingredients_count
belongs_to :ingredient
end
Now that works as expected for the following:
d = Drink.create(:ingredients => [Ingredient.new, Ingredient.new])
d.reload.ingredients_count
=> 2
d.ingredients << Ingredient.new
d.reload.ingredients_count
=> 3
All good, yeh?
d.ingredients.delete(d.ingredients[0])
d.reload.ingredients_count
=> 3
On noes! Deleting a relationship doesn’t update the counter_cache. That’s because of this, in activerecord/lib/active_record/associations/has_many_through_association.rb
# TODO — add dependent option support
def delete_records(records)
klass = @reflection.through_reflection.klass
records.each do |associate|
klass.delete_all(construct_join_attributes(associate))
end
It uses delete_all, so when you delete from the association, the before_destroy callbacks, and hence the counter_cache callbacks aren’t triggered.
The fix I mentioned above is in edge, but for those of us on 3.0.*, I’ve found this to make things work as expected. Add this to the Drink class:
has_many :ingredients, :through => :drink_ingredients, :after_remove => :decrement_ingredients_count
def decrement_ingredients_count(ing)
Drink.decrement_counter(:ingredients_count, id)
end
This manually decrements the counter on the collection’s after_remove callback, which does get called when you delete something from the collection.
This passes the following spec, which I think covers everything — let me know if I’ve missed anything:
it "Has a working ingredients count" do
drink = Drink.create(:name => 'New Drink', :description => 'My new drink',
:ingredients => [Ingredient.new(:name => 'ING1'), Ingredient.new(:name => 'ING2')])
drink.reload.ingredients_count.should == 2
ing3 = Ingredient.create(:name => 'ING3')
drink.ingredients << ing3
drink.reload.ingredients_count.should == 3
drink.ingredients.delete(ing3)
drink.reload.ingredients_count.should == 2
drink.ingredients[0].destroy
drink.reload.ingredients_count.should == 1
drink.ingredients.delete_all
drink.reload.ingredients_count.should == 0
drink = Drink.create(:name => 'New Drink', :description => 'My new drink')
drink.ingredients = [Ingredient.new(:name => 'ING3'), Ingredient.new(:name => 'ING3')]
drink.save
drink.reload.ingredients_count.should == 2
end

Comments
Subscribe Make commentWhich table is your ingredients_count on?
Hi,
The ingredients count is on the drinks table.