Implementing a Simple Counter Cache in Rails (Ruby on Rails)

Active Record has a functionality where you can cache the count of particular items. But it doesn’t do exactly what I want.

Why would anyone want to cache a count?

Let’s take twitter for example. A user might have 1000s of tweets. Twitter wants to show the number of tweets they have made. If a database query were made, every time the total tweet count was displayed, that would be a very expensive operation. Consequently, it pays to cache that value and to update it as and when required.

Active record increments and decrements everything straight out of the box. Except in my particular case, I didn’t want it decremented. A google search didn’t yield any obvious answers as to how I could handle that situation. A quote may have many line_items. We want to keep track of this number in an attribute held on the quote. Let’s call it: line_items_count. I could have hacked active record’s implementation, but I thought it far easier to implement my own. I did it using is a simple before_create call back.

How it works?

When a user creates a line item, we want to store it’s “index” - that is, if it’s the 5th line item created, on a particular quote, then we want its index to read: 5. And then we want to store the total count of all the line items in an attribute on the quote model, and in the quotes table.

Here are the relevant details:

The Schema:

  create_table "line_items", force: :cascade do |t|
    t.integer "quote_id"   
    t.integer "item_index"
    ## Irrelevant details are ommitted
  end

  create_table "quotes", force: :cascade do |t|  
    t.integer "line_items_count", default: 0    
  end

The Migrations:

class AddItemIndexToLineItems < ActiveRecord::Migration[5.2]
  def change
    add_column :line_items, :item_index, :integer
  end
end
class AddLineItemsCountToQuotes < ActiveRecord::Migration[5.2]
  def change
    add_column :quotes, :line_items_count, :integer, :default => 0
  end
end

The models:

class LineItem < ApplicationRecord  
  belongs_to :quote, inverse_of: :line_items  

  before_create :update_counts

  def update_counts
    quote = Quote.find(self.quote_id)
    quote.line_items_count = quote.line_items_count + 1
    quote.save
    self.item_index = quote.line_items_count
  end
end

class Quote < ApplicationRecord
  include AASM      
  has_many :line_items, inverse_of: :quote, dependent: :destroy   
end

That’s all that you need.

You need to run a rake task to ensure that after you’ve run your migrations, that the database is up-to-date.

namespace :line_items do
  desc "update line items with an item index number, update their quotes with a count"
  task update_item_index: :environment do
    Quote.find_each do |quote|

    # WARNING: In my database, I know that each quote has less than 20 line items,
    # so doing so is perfectly save. I also know that I have less than 1000 quotes
    # in the database, so am perfectly comfortable. If you have more records
    # then you will need to run something which will be a little safer.

      quote.line_items.order(:created_at).each_with_index do |line_item, index|
        line_item.update_columns(item_index: index + 1)
      end

      quote.update_columns(line_items_count: quote.line_items.count)
    end
  end
end


Written on October 2, 2019