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) 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.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