Douglas F Shearer

Custom counter_cache with Conditions


The counter cache ability of a model relationship is amongst the most useful in Rails’ Active Record repertoire. In it’s standard form it looks like this:

belongs_to :parent_model, :counter_cache => true

Together with the matching has_many assertion (Or another rule) in the parent model, along with an appropriately named column in the parent table (In the case of our example that would be comments_count.

The Problem

Sometimes it would be great to not count every child object, but only those satisfying specific conditions.. How about having a blog with comments, and marking those comments as spam or not spam. We don’t want to tell our user that there is 3 comments, only for them to see 2, since 1 is spam and not available for public viewing until it has been moderated. In this case we would a custom counter cache, which at a guess could look something like:

belongs_to :post, :counter_cache => true, :conditions => "self.is_spam = false"

Unfortunately that doesn’t work, as it would violate Rails’ rule of convention over configuration. However, there is a solution.

The Solution

The counter cache works by simply updating a parent model’s count of related objects after each save or destroy operation on the child model. If we delete a comment, we expect the counter cache of the appropriate parent object to be decremented by one.

We can use exactly the same methodology when it comes to keeping a custom counter cache. First up, create a new column in your parent table using migrations (You are using migrations right?). Name the column something appropriate, I’ll be using public_comments_count in my example.

Next, we want to add some methods to child model (comment in this case) to update our custom counter cache on each save or destroy. Using after_save and after_destroy methods, we can do this every time and object is manipulated in any way that might affect our counter cache. The code looks as follows:


def after_save
  self.update_counter_cache
end

def after_destroy
  self.update_counter_cache
end

def update_counter_cache
  self.post.public_comments_count = Comment.count( :conditions => ["is_spam = false AND post_id = ?",self.post.id])
  self.post.save
end

Thats it! Now everytime a comment is added, removed or modified, my custom counter cache will be updated.

Hope this helps some people who like me were perplexed by the lack of support for this in Rails.

Did you like my Ruby on Rails related article? Then why not recommend me on Working with Rails?

Tags

, .

Related Posts

October 6th 2006 19:36 | comments (12)
 

Comments


Gravatar

Dylan Bennett

October 9th 2006 21:01

Great warped minds think alike. I just implemented this exact same thing (different column name, though) in my app.

Gravatar

Amr Malik

October 25th 2006 23:24

Thanks for the writeup! one quick question,

Noob question:

does counter_cache update the parent's count every time we save? shouldn't it only update when a child is added or deleted?

I mention this because I have run into this where the counter cache seems to update a classified's parent category even though I'm just saving it. I'm sure it is something I don't understand about the counter_cache which is having this effect ?

Gravatar

Douglas F Shearer

October 26th 2006 18:15

Hi Amr! From my understanding, the counter_cache is updated in a similar style to my custom variation. Instead of having a separate method for creation (after_create()) and deletion (after_destroy()), a single method is used that simply invokes a count of all the objects in the database that are children of an entry in the parent table. Doing this full count rather than saying, 'I have added a new child, increase the count by one', allows the count to remain accurate if objects are modified in some way that affects their relationship with the parent model. This might also catch any manual deletions from the table, by a non-rails app (database management application perhaps). I hope this makes sense.

Gravatar

Jason

November 26th 2006 22:54

This code looks great, but I can't get it to work from the console. Are callbacks implemented in the console (or irb), or can I only test it from Rails tests?

Gravatar

Douglas F Shearer

November 27th 2006 14:51

The three methods above are added to the child model of the relationship, so they should work anytime a model object is added, edited or deleted, whether it be from within the app, or in the console.

I hope that lets you figure out what is going wrong.

Gravatar

Sebastian Munz

November 30th 2006 17:39

But what happens when I change the relationship to the parent while saving? I would get the right child-count in the new parent, but the old one would now have the wrong child-count.

Am I right?

Gravatar

Douglas F Shearer

November 30th 2006 18:04

You are right! A quick modification to the code would prevent this from happening.

Something along the lines of changing the update_counter_cache method to...

for post in Post.find(:all)
post.public_comments_count = Comment.count( :conditions => ["is_spam = false AND post_id = ?",self.post.id])
post.save
end

My justification for doing this in the first place was that it was unlikely that comments would change parent, and the performance hit of writing to the DB. This however is only going to involve two records, so testing if the post object has been modified would prevent unnecessary writes.

Gravatar

Sebastian Munz

November 30th 2006 18:33

Thank you for the quick response.

But I'll think going over all Posts would cause every DB to collapse.

i would suggest something like:

def before_save
self.post.decrement('comments_count')
end

Gravatar

Sebastian Munz

November 30th 2006 18:36

sorry, should be:

self.post.decrement('comments_count').save

Gravatar

Douglas F Shearer

December 1st 2006 12:30

There was some reason I chose the strategy I did, unfortunately I can't remember why it was now. Either method will work fine, although moving child objects between parent objects is a problem that I shall look into in more detail.

Gravatar

Grant

April 5th 2007 02:05

Bit late to the party, but I have to add one caveat: counter_cache is modified directly by the replace method for belongs_to associations. If you use replace on belongs_to associations (for example to merge models) and are simulating counter_cache with filters, you'll have to update the counters manually.

Gravatar

Douglas F Shearer

April 5th 2007 12:35

Interesting, I shall need to test that later to see what I come up with.

Add Your Comments


(Required)

Your email address to get your Gravatar. Address itself is not shown.

(Include the http://)

(Required)

 

You Are Here


Douglas F Shearer

This is the homepage of Douglas F Shearer, a software developer and mountainbike racer. Find out more at the About page.

Gallery Latest


Side on Chips on crown Front on chip2 chip1 img67

Stay Informed


What is RSS?

Top Tags