Martin Petrov
Martin Petrov

Reputation: 2643

How to update counter_cache when updating a model?

I have a simple relationship:

class Item
  belongs_to :container, :counter_cache => true
end

class Container
  has_many :items
end

Let's say I have two containers. I create an item and associate it with the first container. The counter is increased.

Then I decide to associate it with the other container instead. How to update the items_count column of both containers?

I found a possible solution at http://railsforum.com/viewtopic.php?id=39285 .. however I'm a beginner and I don't understand it. Is this the only way to do it?

Upvotes: 6

Views: 8826

Answers (8)

dalf
dalf

Reputation: 602

Sorry I don't have enough reputation to comment the answers.
About fl00r, I may see a problem if there is an error and save return "false", the counter has already been updated but it should have not been updated. So I'm wondering if "after_update :update_counters" is more appropriate.

Curley's answer works but if you are in my case, be careful because it will check all the columns with "_id". In my case it is automatically updating a field that I don't want to be updated.

Here is another suggestion (almost similar to Satish):

def update_counters
   if container_id_changed?
     Container.increment_counter(:items_count, container_id) unless container_id.nil?
     Container.decrement_counter(:items_count, container_id_was) unless container_id_was.nil?
   end
end

Upvotes: 1

skilleo
skilleo

Reputation: 2481

here is an approach that works well for me in similar situations

class Item < ActiveRecord::Base

    after_update :update_items_counts, if: Proc.new { |item| item.collection_id_changed? }

private

    # update the counter_cache column on the changed collections
    def update_items_counts

        self.collection_id_change.each do |id|
            Collection.reset_counters id, :items
        end

    end

end

additional information on dirty object module http://api.rubyonrails.org/classes/ActiveModel/Dirty.html and an old video about them http://railscasts.com/episodes/109-tracking-attribute-changes and documentation on reset_counters http://apidock.com/rails/v3.2.8/ActiveRecord/CounterCache/reset_counters

Upvotes: 2

David Kopf
David Kopf

Reputation: 31

Modified it a bit to handle custom counter cache names (Don't forget to add after_update :fix_updated_counter to the models using counter_cache)

module FixUpdateCounters

  def fix_updated_counters
    self.changes.each { |key, (old_value, new_value)|
      # key should match /master_files_id/ or /bibls_id/
      # value should be an array ['old value', 'new value']
      if key =~ /_id/
        changed_class = key.sub /_id$/, ''
        association   = self.association changed_class.to_sym

        case option = association.options[ :counter_cache ]
        when TrueClass
          counter_name = "#{self.class.name.tableize}_count"
        when Symbol
          counter_name = option.to_s
        end

        next unless counter_name

        association.klass.decrement_counter(counter_name, old_value) if old_value
        association.klass.increment_counter(counter_name, new_value) if new_value
      end
    }   end end

ActiveRecord::Base.send(:include, FixUpdateCounters)

Upvotes: 3

m4tm4t
m4tm4t

Reputation: 2381

Here the @Curley fix to work with namespaced models.

module FixUpdateCounters

  def fix_updated_counters
    self.changes.each {|key, value|
      # key should match /master_files_id/ or /bibls_id/
      # value should be an array ['old value', 'new value']
      if key =~ /_id/
        changed_class = key.sub(/_id/, '')

        # Get real class of changed attribute, so work both with namespaced/normal models
        klass = self.association(changed_class.to_sym).klass

        # Namespaced model return a slash, split it.
        unless (counter_name = "#{self.class.name.underscore.pluralize.split("/")[1]}_count".to_sym)
          counter_name = "#{self.class.name.underscore.pluralize}_count".to_sym
        end

        klass.decrement_counter(counter_name, value[0]) unless value[0] == nil
        klass.increment_counter(counter_name, value[1]) unless value[1] == nil
      end
    }
  end 
end

ActiveRecord::Base.send(:include, FixUpdateCounters)

Upvotes: 1

Curley
Curley

Reputation: 1621

I recently came across this same problem (Rails 3.2.3). Looks like it has yet to be fixed, so I had to go ahead and make a fix. Below is how I amended ActiveRecord::Base and utilize after_update callback to keep my counter_caches in sync.

Extend ActiveRecord::Base

Create a new file lib/fix_counters_update.rb with the following:

module FixUpdateCounters

  def fix_updated_counters
    self.changes.each {|key, value|
      # key should match /master_files_id/ or /bibls_id/
      # value should be an array ['old value', 'new value']
      if key =~ /_id/
        changed_class = key.sub(/_id/, '')
        changed_class.camelcase.constantize.decrement_counter(:"#{self.class.name.underscore.pluralize}_count", value[0]) unless value[0] == nil
        changed_class.camelcase.constantize.increment_counter(:"#{self.class.name.underscore.pluralize}_count", value[1]) unless value[1] == nil
      end
    }
  end 
end

ActiveRecord::Base.send(:include, FixUpdateCounters)

The above code uses the ActiveModel::Dirty method changes which returns a hash containing the attribute changed and an array of both the old value and new value. By testing the attribute to see if it is a relationship (i.e. ends with /_id/), you can conditionally determine whether decrement_counter and/or increment_counter need be run. It is essnetial to test for the presence of nil in the array, otherwise errors will result.

Add to Initializers

Create a new file config/initializers/active_record_extensions.rb with the following:

require 'fix_update_counters'

Add to models

For each model you want the counter caches updated add the callback:

class Comment < ActiveRecord::Base
  after_update :fix_updated_counters
  ....
end

Upvotes: 1

Satish
Satish

Reputation: 2043

Updates to @fl00r Answer

class Container
  has_many :items_count
end

class Item
  belongs_to :container, :counter_cache => true
  after_update :update_counters

  private

 def update_counters
   if container_id_changed?
     Container.increment_counter(:items_count, container_id)
     Container.decrement_counter(:items_count, container_id_was)
   end

   # other counters if any
   ...
   ...

 end

end

Upvotes: 1

molmole
molmole

Reputation: 21

For rails 3.1 users. With rails 3.1, the answer doesn't work. The following works for me.

  private
    def update_counters
      new_container = Container.find self.container_id
      Container.increment_counter(:items_count, new_container)
      if self.container_id_was.present?
        old_container = Container.find self.container_id_was
        Container.decrement_counter(:items_count, old_container)
      end
    end

Upvotes: 2

fl00r
fl00r

Reputation: 83680

It should work automatically. When you are updating items.container_id it will decreament old container's counter and increament new one. But if it isn't works - it is strange. You can try this callback:

class Item
  belongs_to :container, :counter_cache => true
  before_save :update_counters

  private
  def update_counters
    new_container = Container.find self.container_id
    old_container = Container.find self.container_id_was
    new_container.increament(:items_count)
    old_container.decreament(:items_count)
  end
end

UPD

To demonstrate native behavior:

container1 = Container.create :title => "container 1"
#=> #<Container title: "container 1", :items_count: nil>
container2 = Container.create :title => "container 2"
#=> #<Container title: "container 2", :items_count: nil>
item = container1.items.create(:title => "item 1")
Container.first
#=> #<Container title: "container 1", :items_count: 1>
Container.last
#=> #<Container title: "container 1", :items_count: nil>
item.container = Container.last
item.save
Container.first
#=> #<Container title: "container 1", :items_count: 0>
Container.last
#=> #<Container title: "container 1", :items_count: 1>

So it should work without any hacking. From the box.

Upvotes: 3

Related Questions