Let’s pretend that we’re Rails developers at Widgets Inc, and our boss asks us to figure out which month was the best for widget production. Let’s assume there’s nothing complicated about our Widget model, and our back end is some SQL flavor. We’ll also assume that timezones don’t exist for simplicity.
To do this, we’ll get the count of Widget objects and group that by month. We’ll
assume we have a
created_at column on our Widgets table.
This isn’t quite what we want. Say our widget counts look like this:
Our query tells us we created 20 widgets in March and 15 in April, which is
technically correct, but probably not what our boss wants. We need to add the
year to the grouping. We can pass this as an array to
That looks better! We can clearly see that April 2013 was the biggest month for widget production. Let’s add that to our model.
We want to call
best_month_ever directly on
Widget, so define it as a
class method. Keep in mind that the preceding code will return a hash of all
counts for all months. Since
Hash includes the
Enumerable module, we can
.max on the result set. That will work, but it would be nice if our
database results were already sorted. Let’s look at the query that ActiveRecord
generates and see how we could accomplish that.
You can see that our count is aliased as ‘count_all’. Let’s try ordering by that.
That pushed the sorting to the database. This solution is somewhat brittle
because if future versions of ActiveRecord change how counts are aliased,
this code could stop working. As far as I know there is no way to set
the alias that the
.count method will use. Considering there will be
12 counts per year, even if we’ve been producing widgets for 100 years,
that’s going to be a relatively small hash. I think in this case, it’s
fine to do the sort on the hash, rather than in the database.
So far, so good. If you think about it though, the historical data isn’t going to change, so there’s no need to run that query every time. The only count that changes is the count for the current month (as we continue to produce widgets.) Let’s cache the historical record, and just get the count for the current month. If the current month is our ‘best month ever’ we’ll break the cache and update the record. The first piece is a scope for the current month.
Now that we have the count for the current month, we can make the modifications
record is an
OrderedHash which will look something like this
[[2013, 4], 14], so when comparing it to
this_month (which is just
an integer) we only care about the count, which is the last item in
the array. That’s where the
.last comes from.
We deliver this solution and the management loves it. “But wait, we want weekly
records as well,” they say. It’s probably a good idea at this point to make the
code a little more flexible. It’s simple to get the weekly record, only the
.group call has to change. We could copy/paste and be done in 5 seconds, but
what fun is that? Let’s rewrite this to be flexible. We’ll modify the method
to accept a time frame, something like ‘month’, ‘week’ or ‘day’.
Since we’re passing the time_frame argument directly to the database, we need to
make sure that only the supported date functions get through. We’ll raise an
argument error if we see anything other than ‘month’, ‘week’ or ‘day’. Don’t
forget to set up the scopes too. I won’t go through them because they’re almost
identical to the
this_month scope that we already defined.
One of the beautiful things about Ruby and Rails is how expressive the code can
be. Right now, we can call
Widget.record_for('month'), which is fine but I’m
going to add a little syntactic sugar on top of that. I’d rather call
Widget.record_for_month. There’s really no difference, but it’s ridiculously
easy to metaprogram Ruby, so let’s add a few aliases. Since we need to pass
an argument into
record_for we can’t just use
alias_method, but that is fine.
That’s pretty good. We have a flexible method and some nice syntactic sugar. Management loves it. Now they want historical record counts on all of our models. Time to make this a module and get just a little more meta.
It’s easy to go overboard with metaprogramming, especially when it’s this easy. But I think our solution is flexible without being difficult to understand. All that’s left to do is submit a pull request and enjoy a hard-earned cup of coffee.