Hi. I am travis and I live and work in a place long ago abandoned by the gods: New York City. By day, I am a web developer at Blue Apron. By night, i am a normal person.

Counting is Hard

You would think we'd have figured this one out by now. Scientists are shredding gravity waves left and right and fighting cancer with armies of AIDS, but knowing how many shoes a person bought on your site represents a real challenge.

Why is it so difficult, you ask? In any good framework, you can probably just call .count() on a model, after all... Sure, if your roommate and your mom are the only people using your site, then no worries! However, we are big boys and girls and we know that these operations can be very expensive indeed. In Postgres, for example, a count query on a large table (millions of records) can take multiple seconds. If you plan on doing this a lot, your site's performance is going to suffer.

Rails has this concept of a "counter_cache," but I don't know how I feel about it. Right out of the gate, you need to add a new column to your model, and that makes my head hurt. Other people have created some very robust solutions to the very interesting problem of counting, e.g. the counter-cache gem. There is some beautiful stuff happening here, but this gem is a tactical nuclear weapon when you might only need a slingshot.

Plus, sometimes it's more fun to roll your own! And so, we begin...

module Counter
  extend ActiveSupport::Concern

  included do
    def redis_count
      (seed_count || redis.get(count_key)).to_i
    end

    def increment_count
      seed_count and redis.incr(count_key)
    end

    def decrement_count
      seed_count and redis.decr(count_key)
    end

    def reset_count
      redis.del(count_key)
    end

    private

    def redis
      @redis ||= Redis.current
    end

    def count_key
      "#{self.class}_#{id}_counter"
    end

    def relation_to_count
      raise NotImplementedError
    end

    # Returns nil if count was not previously set
    def seed_count
      redis.get(count_key).tap do |count|
        redis.set(count_key, relation_to_count.count) if count.nil?
      end
    end
  end
end

Voila! This module uses Redis to store the current count of a relation (or pretty much anything else) and abstracts away the need to futz with Redis directly. The seed_count method queries the current count from Redis, and when the value is undefined, it runs the expensive count query once (ever) to populate the value.

This is probably the first time that I've found Ruby's and operator to be extremely handy. Imagine calling increment_count, for example, twice in a row. In the first call, the value is undefined in Redis, so the seed_count method runs the count query and returns nil. This short-circuits execution in increment_count, so that the count isn't unexpectedly incremented again. Now, when we call the method a second time, seed_count actually returns a value such that redis.incr(count_key) actually runs.

Now we have a module that we can include wherever, and all we have to do is implement a single interface method to drive the counter, namely relation_to_count:

class User < ActiveRecord::Base
  include Counter

  has_many :orders
  
  alias_method :red_shoe_count, :redis_count

  private
  
  def relation_to_count
    orders.where(type: 'shoes', color: 'red')
  end
end

In your model, you define the method and build the query you've always dreamed of building. Just make sure that you return a relation (or some other object) that responds to .count

Phew, I'm exhausted. We're still not quite done yet, but I will leave it to you to decide when and where to increment/decrement the counter. If you're a fan of Sidekiq, you can incorporate a job like this: 

class CountingJob
  include Sidekiq::Worker

  def perform(klass, id, increment = true)
    obj = klass.constantize.find(id)
    return unless obj.present?

    if increment
      obj.increment_count
    else
      obj.decrement_count
    end
  end
end

Godspeed! I believe in you!

This Cat is Your God

If it Barks Like a Dog