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!