Ruby is a great language. Really. How many other languages let you extend other classes? Modify their behavior? Overload operators? With great power comes great opportunity, and in this post Ill cover one of my recent extensions to Ruby that was recently accepted into Rails core.
Date and Time
Managing dates and times is not exactly fun in any language. There are time zones, leap years, two meridians, twenty-four hours, 365 days (sometimes), and a mess of inconsistently long months. Those of us who use Ruby frequently are familiar with how to get objects corresponding to the current time and the current day:
-
>> Time.now
-
=> Thu Jan 25 20:12:50 -0800 2007
-
>> Date.today
-
=> #<Date: 4908251/2,0,2299161T>
Sexy methods
Rails has these really cool extensions to Numeric
that allow computing lengths of time in a more human-readable way. For example, if I wanted to figure out how long 12 hours is, I could just call 12.hours
and get back the number of seconds in 12 hours, which could then be conveniently added to an instance of Time
, like so:
-
>> t = Time.now
-
=> Thu Jan 25 19:31:17 -0800 2007
-
>> t + 12.hours
-
=> Fri Jan 26 07:31:17 -0800 2007
Sweet! Whats even cooler though is that, in addition to Numeric
with seconds
, minutes
, hours
, days
, weeks
, fortnights
, months
, and years
, we also get from_now
, ago
, since
, and until
:
-
>> 1.day.from_now
-
=> Fri Jan 26 19:34:20 -0800 2007
-
>> 2.weeks.ago
-
=> Thu Jan 11 19:34:35 -0800 2007
but kinda dumb.
Nice! These are so easy to read. These were great for simple things, but since they returned numbers, they couldn’t take into account the current date and how many days were in a month, leap year, etc. Notice that this causes problems when the month doesn’t have 30 days in it:
-
>> Time.now
-
=> Thu Jan 25 21:01:31 -0800 2007
-
>> 1.month.from_now
-
=> Sun Feb 24 21:01:34 -0800 2007
So its good for approximations, but not much else. Well what now?
A smart method
To compensate for the lack of accuracy in the above sexy methods, Time#advance
was added to make it easy to do smart addition to Time
instances. Here’s some of the above using advance
:
-
>> t = Time.now
-
=> Thu Jan 25 21:11:32 -0800 2007
-
>> t.advance:hours => 12
-
=> Thu Jan 25 21:11:32 -0800 2007
-
>> t.advance:months => 1
-
=> Sun Feb 25 21:11:32 -0800 2007
Excellent! Not quite as sexy, but definitely smart. A method suitable for Serious Business.
Smart n Sexy
I welcomed Time#advance
, like everyone else, but I didnt want to give up the sexy methods. Fortunately, Ruby is great for letting objects masquerade as other objects (the whole duck typing thing), and the de-facto way to do this in Rails is by using Builder::BlankSlate
, whose instance undefine almost all of their methods, allowing you to easily proxy all or some methods to something else. The two best examples of its use in Rails are AssociationProxy
, which is used by Active Record, and JavaScriptGenerator
, which is used by RJS.
To solve the problem, all we need to do is make the duration methods on Numeric
return a proxy for the number they used to return which acts accordingly around Time
and Date
objects. This new class is located in ActiveSupport::Duration
, and simply accumulates lengths of time for when it is used around a Time
or Date
, and a number for use around things that expect it to act like a number (i.e. for backward compatibility). Heres the new, smart and sexy, methods on Numeric
:
-
>> t = Time.now
-
=> Thu Jan 25 21:21:44 -0800 2007
-
>> t + 1.month
-
=> Sun Feb 25 21:21:44 -0800 2007
-
>> t + 1.week
-
=> Thu Feb 01 21:21:44 -0800 2007
-
>> 1.year.from_now
-
=> Fri Jan 25 21:22:00 -0800 2008
-
>> 4.years.from_now
-
=> Tue Jan 25 21:22:12 -0800 2011
-
>> 3.weeks
-
=> 21 days
Hows that last one for overriding inspect
? To try it out all you have to do is freeze edge in a Rails project near you!
$ rake rails:freeze:edge
Update: I should add that this recent change also corrected handling around Date
objects. I implied that above, but I should be explicit. Before Duration
, adding Date.today
and 1.day
didnt yield the expected results at all:
-
-
>> Date.today + 1.day.to_time
-
ArgumentError: time out of range
-
Now it behaves as expected:
-
-
>> Date.today + 1.day.to_time
-
=> Sat Jan 27 00:00:00 -0800 2007
-