Lighthouse has a new layout. Prefer the old one? Return to the old layout, and switch back any time from the link at the top of each page.

This project is archived and is in readonly mode.

Add conditional counter cache

#3521

One common pattern that I keep repeating is lots of repeated code to handle cache counters based on a flag or status. Take the example of Posts & Comments. I want a counter cache for comments, but I don't want to include ones that are deleted. This should also take into account updates as well as creates & deletes.

Here's an example of the syntax:

belongs_to :post, :counter_cache => {:condition => lambda {|c| !c.deleted } }

This only increments or decrements the counter if the lambda returns true (and the lambda has self passed in).

I've added support for this in the following patch. I've also included unit tests for this functionality. This works in 2-3-stable, but I haven't tested against master.

Reported by Scott Windsor · November 30th, 2009 @ 07:53 AM

State: new
Milestone: none
Assigned to: nobody
Importance: Low

Activity

  1. Les Nightingill
    Les Nightingill

    Not convinced that the condition pattern is as common as Scott suggests, but I definitely feel that the omission of the counter_cache increment/decrement method for updates is an oversight and should be included in Rails.

    December 12th, 2009 @ 10:48 AM

  2. Scott Windsor
    Scott Windsor

    The conditional happens more frequently in my code because I tend to use status columns on all of my tables. That way, I only ever soft delete something (changing the status rather than actually deleting the row). But, if the status is deleted, the user likely doesn't see the object listed anymore, so the counts are off. I also always have to write multiple callbacks as well - since checking a column for changes doesn't work on create (and there's no way to tell if an object is new in after_save), I have to make two callbacks, one for after_update and one for after_create - and they usually both have very similar logic. Mostly, I'd just like to cut down on the redundant code in my models.

    December 12th, 2009 @ 03:48 PM

  3. Cyrille
    Cyrille

    +1

    Imagine if your models have state machine most of the time you want to count only 'active' elements.
    Very common e-commerce case : product and category. In front office you might want to display very often number of "active" products per category. Make sense. Same for forum posts where posts can be soft deleted for moderation. Another great one is comments and spam you might want to display only the number of comments not marked as spam or under_moderation

    January 7th, 2010 @ 09:47 AM

  4. cricy
    cricy

    belongs_to :post, :counter_cache => (:deleted ? 1 :0)

    January 26th, 2010 @ 08:35 AM

  5. cricy
    cricy

    belongs_to :post, :counter_cache => (:deleted ? true : false )

    January 26th, 2010 @ 08:36 AM

  6. Scott Windsor
    Scott Windsor

    cricy, I don't quite think that sytanx would work because it's not valid ruby. belongs_to is still a function call that takes arguments (and in the above cases a symbol, then a hash with options. Additionally, the ":deleted ? true : false" expression would be evaluated too early (at class compilation time, and not at execution time). The only real way around this is a lambda. I could simplify the code to:

    belongs_to :post, :counter_cache => lambda {|c| !c.deleted }

    But then, there's no way for the existing option of having a custom column name to be passed in W

    Which is what the current options allow:

    belongs_to :post, :counter_cache => :users_postz

    So the only way to allow both of these existing options is to provide a hash as the argument, in which case the logic can handle both options (and future options as well).

    belongs_to :post, :counter_cache => {:condition => lambda {|c| !c.deleted }, :cache_column => :user_postz}

    January 26th, 2010 @ 04:41 PM

  7. Jim Meyer
    Jim Meyer

    Definite +1 for the functionality. The patch as offered is a good start, but will break some existing functionality. For example, it's perhaps not well known that this is a valid counter cache declaration:

    class Comment
      belongs_to :post, :counter_cache => 'visible_comments_count'
    end
    

    This works because ActiveRecord::Reflection::AssociationReflection#counter_cache_column does this:

      def counter_cache_column
        if options[:counter_cache] == true
          "#{active_record.name.demodulize.underscore.pluralize}_count"
        elsif options[:counter_cache]
          options[:counter_cache]
        end
      end
    

    ... so slinging a Hash as :counter_cache won't work. Perhaps better to pass it as :counter_cache_conditions.

    The other breakage has to do with ActiveRecord::Base.reset_counters, which currently expects to operate entirely inside the database. If the counter cache's conditions aren't able to be reduced to SQL, reset_counters must load all counted objects and consider them in Rails-space, which chews up memory and is not likely to perform well for large numbers of counted objects.

    I'm working on a fix as a plugin inside an app which needs this; if I manage it well, I'll gemify it and share.

    August 24th, 2010 @ 12:07 AM

  8. Ryan Bigg
    Ryan Bigg
    • Tag cleared.
    • Importance changed from to Low

    Automatic cleanup of spam.

    October 9th, 2010 @ 10:11 PM

  9. Jeff Kreeftmeijer
    Jeff Kreeftmeijer
    • Title changed from [PATCH] Add conditional counter cache to Add conditional counter cache
    • Tag set to activerecord, patch

    Using the "patch" tag instead of prefixing the ticket title with "[PATCH]" to make sure patched tickets end up in the open patches bin. :)

    October 10th, 2010 @ 01:03 PM

  10. Szymon Nowak
    Szymon Nowak

    Wouldn't actually moving counter_cache declaration from belongs_to to has_many solve this problem?

    class Post
      has_many :likes, :class_name => "Vote", :conditions => { :flag => true }, :counter_cache => true
      has_many :dislikes, :class_name => "Vote", :conditions => { :flag => false }, :counter_cache => "some_fancy_column_name"
    end
    
    class Vote
      belongs_to :post
    end
    
    Post.first.likes.size # could use proper counter cache
    

    April 27th, 2011 @ 09:37 AM

  11. swindsor
    swindsor

    That might be another solution - it's less backwards compatible, but might be a good direction. If you want to add a patch, feel free. Until then, I'm still waiting on someone on the rails team to actually look at this. =/

    April 27th, 2011 @ 05:18 PM