Rails Duration

ruby on rails

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:

  1. >> Time.now
  2. => Thu Jan 25 20:12:50 -0800 2007
  3. >> Date.today
  4. => #<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:

  1. >> t = Time.now
  2. => Thu Jan 25 19:31:17 -0800 2007
  3. >> t + 12.hours
  4. => 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. >> 1.day.from_now
  2. => Fri Jan 26 19:34:20 -0800 2007
  3. >> 2.weeks.ago
  4. => 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:

  1. >> Time.now
  2. => Thu Jan 25 21:01:31 -0800 2007
  3. >> 1.month.from_now
  4. => 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:

  1. >> t = Time.now
  2. => Thu Jan 25 21:11:32 -0800 2007
  3. >> t.advance:hours => 12
  4. => Thu Jan 25 21:11:32 -0800 2007
  5. >> t.advance:months => 1
  6. => 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:

  1. >> t = Time.now
  2. => Thu Jan 25 21:21:44 -0800 2007
  3. >> t + 1.month
  4. => Sun Feb 25 21:21:44 -0800 2007
  5. >> t + 1.week
  6. => Thu Feb 01 21:21:44 -0800 2007
  7. >> 1.year.from_now
  8. => Fri Jan 25 21:22:00 -0800 2008
  9. >> 4.years.from_now
  10. => Tue Jan 25 21:22:12 -0800 2011
  11. >> 3.weeks
  12. => 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:

  1. >> Date.today + 1.day.to_time
  2. ArgumentError: time out of range

Now it behaves as expected:

  1. >> Date.today + 1.day.to_time
  2. => Sat Jan 27 00:00:00 -0800 2007