I was playing around in Swift recently, and wanted a reference to ‘one day ago’. This is simple enough in human terms: if it’s 9:30 on the 18th of September, ‘one day ago’ means 9:30 on the 17th of September.
The Swift code to do this looks like this:
Calendar.current.date( byAdding: .day, value: -1, to: Date.now )
…and that’s just a bit much, isn’t it?
First we need to access Calendar.current
, a singleton on Calendar
which returns whatever calendar is in use. (Gregorian, Islamic, Chinese…)
Then we need to tell the calendar to calculate a date by adding a DateComponents
object to Date.now
. Good luck parsing this initialiser if you’ve never done that before.
And after all that, we don’t have a Date
object! We have an optional Date?
, and like, come on man. Yesterday definitely exists, but now I feel bad about force unwrapping it.
The equivalent code in Rails is this:
1.day.ago
…yeah.
Knowing this existed, I started extending Swift’s Int
and DateComponents
classes to support this beautiful, detail-abstracting, human-readable syntax. (Here’s the result, for any interested Swift people!)
In the process of re-implementing this, I started wondering. In Rails I am liberal when calling ago
or since
, but I really don’t know what goes on under the hood when I do so. Despite knowing date logic to be the eternal folly of the curious programmer, I decided I wanted to understand exactly how these Date calculations were being done. Or at least, I wanted to understand to the best of my abilities.
If you too are so cursed, read on!
Active Support
Here is the relevant code that ActiveSupport adds to Ruby’s Numeric
, but it isn’t very interesting:
def days ActiveSupport::Duration.days(self) end alias :day :days
All this tells us is that the real work for days
is done in a dedicated class called Duration. Looking around, we find ago
and since
living here as well!
module ActiveSupport class Duration ... def ago(time = ::Time.current) sum(-1, time) end def since(time = ::Time.current) sum(1, time) end ... private def sum(sign, time = ::Time.current) unless time.acts_like?(:time) || time.acts_like?(:date) raise ::ArgumentError, "expected a time or date, got #{time.inspect}" end if @parts.empty? time.since(sign * value) else @parts.inject(time) do |t, (type, number)| if type == :seconds t.since(sign * number) elsif type == :minutes t.since(sign * number * 60) elsif type == :hours t.since(sign * number * 3600) else t.advance(type => sign * number) end end end end end end
There’s a lot here, so let’s begin with ago
and since
. (We’ll come back to days
, I promise!) We notice immediately that these methods are just syntactic sugar around sum
– let’s drill down.
<sidenote>
If you’ve ever wondered, ago
and since
are the OG names of these functions; until
, before
, from_now
, and after
are all aliases. Personally I would prefer hence
to since
, but to each their own! ¯\_(ツ)_/¯
</sidenote>
The driving force of sum
is a call to @parts.inject
. inject
is an alias for a method you may know as reduce
, which takes an enumerable and acts on each of its elements in turn to produce a single value; a one-dimensional array is reduced to a zero-dimensional scalar value.
inject
is also given a Time
object, which is included and updated in each iteration. Time
starts with a default value of ::Time.current
, and each element of @parts
updates it, until we have reduced our @parts
array into a single time value.
So a Duration
has some @parts
, which all get sum
‘ed up when we want to calculate anything. But what, exactly, is a Time
, and what kind of thing is contained in @parts
?
Telling the Time
Time
objects are stored internally as a UNIX timestamp, the number of seconds that have passed since January 1, 1970.
In ActiveSupport::Duration#sum
above, we are making use of Time
‘s since
method, which is added by ActiveSupport as a Core Extension. This is a different method with a different signature to Duration
‘s since
.
Actually, this is a much simpler version of since
, accepting an integer number of seconds. Since a Time
is also fundamentally a number of seconds, this method is just addition.
So that’s nice and straightforward – now what about the @parts
array?
<sidenote>
If we wanted to go further, we could go down the rabbit hole of trying to explain exactly how we get the timestamp for Time.current
– that is, how is it that computers can tell the current time?
Turns out this reduces from a computer problem to a physics problem. If like me you weren’t aware, there’s a crystal inside your computer that vibrates at an incredibly consistent frequency when you apply electricity to it. All computers are doing this even when turned off, and they can use this consistent oscillation to accurately track the passage of time.
</sidenote>
Date Components
Let’s now return to how days
is defined on ActiveSupport::Duration
, which we skipped over earlier:
SECONDS_PER_MINUTE = 60 SECONDS_PER_HOUR = 3600 SECONDS_PER_DAY = 86400 SECONDS_PER_WEEK = 604800 SECONDS_PER_MONTH = 2629746 # 1/12 of a gregorian year SECONDS_PER_YEAR = 31556952 # length of a gregorian year (365.2425 days) class << self ... def days(value) # :nodoc: new(value * SECONDS_PER_DAY, { days: value }, true) end ... end def initialize(value, parts, variable = nil) # :nodoc: @value, @parts = value, parts @parts.reject! { |k, v| v.zero? } unless value == 0 @parts.freeze ... end
We can see here that the days
method is just a fancy wrapper over Duration
‘s initialiser. This initialiser reveals to use what our @parts
array looks like: a hash keyed by symbols that represent different human date measurements. The definitions of the other date components (week, month, etc.) are much the same, using the corresponding number of seconds at the top of this code snippet.
If we check the number of seconds given for a month, we have a concern: it is defined as 1/12th of a Gregorian year. This is fine for when we want to talk about durations in the abstract (think about what the result of 3.months.to_i
should be!) but real months are obviously not equal length. When we anchor ourselves in time (i.e. when asking for a duration from or since a given date), we care which month we’re talking about.
Seconds, minutes, and hours are the only date components that don’t have this problem, and are of reliably equal length – you can always reliably factor in or out 60
s to go between them. (No, we’re not talking about leap seconds here. Google it.)
We already saw this relationship earlier, in sum
:
if type == :seconds t.since(sign * number) elsif type == :minutes t.since(sign * number * 60) elsif type == :hours t.since(sign * number * 3600) else t.advance(type => sign * number) end
and we can also see from this that the interesting date components are handled elsewhere, in Time#advance
. This is added by ActiveSupport in another extension – let’s keep following the rabbit hole down.
Advance, Advance, and… what?
Here‘s the Time#advance
method:
def advance(options) ... #fractional date handling that we don't need to worry about! d = to_date.gregorian.advance(options) time_advanced_by_date = change(year: d.year, month: d.month, day: d.day) seconds_to_advance = \ options.fetch(:seconds, 0) + options.fetch(:minutes, 0) * 60 + options.fetch(:hours, 0) * 3600 if seconds_to_advance.zero? time_advanced_by_date else time_advanced_by_date.since(seconds_to_advance) end end
We see that this function handles date and time separately: the easy date components (second, minute, hour) are converted to seconds and added afterwards as a flat number.
However, it doesn’t deal with the pure date components directly (noticing a pattern yet?). Instead, it computes some new date components with a call to_date.gregorian.advance
and swaps its existing date components with these new ones by a call to change
. (Yes, change
does other stuff, but we’re not getting into it!)
By my count, the actual advancement logic we’re trying to pin down has been deferred four times now, but I’m happy to tell you that this is end of the rabbit hole. We’ve reached a function that is going to advance a date by a date component using pure Ruby. Surely this is where ActiveSupport reveals its secrets?
Here it is:
def advance(options) d = self d = d >> options[:years] * 12 if options[:years] d = d >> options[:months] if options[:months] d = d + options[:weeks] * 7 if options[:weeks] d = d + options[:days] if options[:days] d end
…where’s my date logic? And what on Earth is the >>
operator doing here??
Wait, what?
Most readers are probably familiar with <<
as it is defined on an array, to append a new element. This looks like it may be related, but there’s no array here.
In a moment of insanity I tried to convince myself we’re dealing with the bitwise right shift operator. (And that consequently, that Ruby was doing something wild with how it stored dates.)
This is neither of those things, however. As it happens, Ruby actually defines a unique meaning for <<
and >>
on Date. They simply increment or decrement the date in months by the number passed on the right. Try it!
This is pretty cool. In terms of our investigation though, we’re back where we started. We’ve simply discovered a new lower level way of advancing by a month or a year: x.months.from_now
is equivalent to Date.now >> x
!
The Actual Implementation!
This does give us a thread to follow though. We’ve cut through all that sweet ActiveSupport treacle and found a native Ruby function that just has to deal with the fact that the length of a month is not consistent.
There’s a small problem, however. This is, apparently, the definition of >>
:
static VALUE d_lite_rshift(VALUE self, VALUE other) { VALUE t, y, nth, rjd2; int m, d, rjd; double sg; get_d1(self); t = f_add3(f_mul(m_real_year(dat), INT2FIX(12)), INT2FIX(m_mon(dat) - 1), other); if (FIXNUM_P(t)) { long it = FIX2LONG(t); y = LONG2NUM(DIV(it, 12)); it = MOD(it, 12); m = (int)it + 1; } else { y = f_idiv(t, INT2FIX(12)); t = f_mod(t, INT2FIX(12)); m = FIX2INT(t) + 1; } d = m_mday(dat); sg = m_sg(dat); while (1) { int ry, rm, rd, ns; if (valid_civil_p(y, m, d, sg, &nth, &ry, &rm, &rd, &rjd, &ns)) break; if (--d < 1) rb_raise(eDateError, "invalid date"); } encode_jd(nth, rjd, &rjd2); return d_lite_plus(self, f_sub(rjd2, m_real_local_jd(dat))); }
…and for those of you that know C, I hope that’s helpful. But I sure don’t! ¯\_(ツ)_/¯
Conclusion
So, in short, how does 1.day.ago
work?
- ActiveSupport introduces a
Duration
class which stores date components (seconds, minutes, hours, etc) separately. Duration
supports arithmetic methods, and will perform those methods separately on each component- Second, minute, and hour are handled directly by ActiveSupport
- Larger components are subject to the whims of human calendars and require special logic
- Larger date components are deferred to ruby, with an underlying C implementation.
I hope some people have managed to take fraction of what I got from this investigation, which is an improved respect and understanding for ActiveSupport, what it provides, and how it works. But, if you were hoping for some straightforward takeaways on dates, you might be a tad disappointed.
(I supposed you could start using Date.current >> 1
instead of 1.month.from_now
. When the review feedback comes in, just point your coworker to this article. You saved so many lines of ActiveSupport code!)