Reputation: 2643
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
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
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
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
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
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
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
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
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