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

Pingbacks

10.07.2011 18:15 Rails 3 &#8211; How do create counter cached column? - Programmers Goodies @programmersgoodies.com
But see a really nice blog post here, you’ll be able to implement it directly following the advice there.

Comments

Make comment

Hi,

The ingredients count is on the drinks table.