This article was originally written by Jonathan Miles on the Honeybadger Developer Blog.
Rails has been described as a dialect of Ruby. If you’ve ever tried to program in pure Ruby after spending most of your time in Rails, you’ll understand why. Rails provides a lot of useful methods in core Ruby classes; for example, in Rails, nil
allows you to call .present?
, .blank?
, .to_i
, .to_a
, and many more methods, all of which would raise an exception in a pure Ruby file.
Rails does this by "monkey patching" some of Ruby's classes, which refers to adding methods to a class that are not in the original class definition. This is a powerful feature of Ruby and is what allows Rails to create its unique 'dialect'. One of the most well known of these extensions is the date/time helpers that let you write code like 3.days.ago
.
Date manipulation is a tricky part of programming, and these helpers are pretty smart, but they can get you in trouble if you're not aware of what they are doing. Take a look at these two lines of code:
Time.current + 1.month + 1.day
Time.current + 1.day + 1.month
Do these lines produce the same results? It depends when you run them. To understand why, we need to understand a bit about how these methods work so that we can use them effectively.
Unix time
In ye olden days of computers, programmers needed a way to manage Datetimes with some degree of accuracy. This lead to what became known as “Unix time”: an integer value holding the number of seconds since midnight January 1st 1970.
This is still used as the underlying way to store Datetimes in many systems.
Ruby lets us convert dates to these values:
> Time.new
=> 2022-08-20 11:36:26.785387828 +1200
> Time.new.to_i
=> 1660952189 # Unix time
If we want to add an amount of time, then we just need to work out how many seconds we need. Therefore, we could add one day’s worth of seconds to change the time to tomorrow:
> time = Time.new
=> 2022-08-20 11:40:27 +1200
> time + 86_400
=> 2022-08-21 11:40:27 +1200
> time + (24 * 60 * 60) # more verbose version
=> 2022-08-21 11:40:27 +1200
The .day and .days ActiveSupport helpers
Instead of developers across the world implementing their own days-to-seconds methods, ActiveSupport helpfully gives us one out-of-the-box in the form of .day
and .days
methods on Numerics:
> 1.day
=> 1 day
> 1.day.to_i
=> 86400
> (2.7).days.to_i # works with all Numeric types, not just integers
=> 233280
> (2.7 * 24 * 60 * 60).round #equivalent calculation to 2.7 days
=> 233280
ActiveSupport::Duration
Although the value returned by the .day
helper does a good job of imitating an integer, it’s not an integer. When you call .day
or any of the other calendar-related helpers on Numerics, what you get back is an ActiveSupport::Duration
. If we look at some of the other helpers, we can see why this is the case; we’ll choose .month
here.
First, unlike day
, we can’t have .month
just return a fixed integer; because the months have different durations, it could be anywhere from 28 to 31 days. Let’s start with January 30th and add a month to it:
> time = Time.new(2022, 1, 30)
=> 2022-01-30 00:00:00 +1300
> time + 1.month
=> 2022-02-28 00:00:00 +1300
Here we see that the value has been capped to Feb 28th. In most cases, this is probably what we want so that February isn’t skipped. However, this also creates a counter-intuitive situation that could cause problems in a codebase:
Time.new + 1.month + 1.day
Time.new + 1.day + 1.month
These lines look like they should give the same result. However, because of this “capping” behavior, they may not, depending on the time of year these commands are run. Indeed, our CI had failures due to this discrepancy that only showed up in late January.
time = Time.new(2022, 1, 30)
=> 2022-01-30 00:00:00 +1300
> time + 1.month + 1.day
=> 2022-03-01 00:00:00 +1300
> time + 1.day + 1.month
=> 2022-02-28 00:00:00 +1300
> time + (1.day + 1.month) # even adding brackets doesn't change it
=> 2022-02-28 00:00:00 +1300
> time + (1.month + 1.day)
=> 2022-03-01 00:00:00 +1300
The order of operations here will determine which result is returned, and even adding brackets doesn’t help. What’s going on inside ActiveSupport::Duration
? How does it know February only has 28 days? Let’s dive in and take a look.
ActiveSupport::Duration source
Looking at the source for Duration
, we’ll start with the method for addition, as it should give us some clues to what is going on:
# Adds another Duration or a Numeric to this Duration. Numeric values
# are treated as seconds.
def +(other)
if Duration === other
parts = @parts.merge(other._parts) do |_key, value, other_value|
value + other_value
end
Duration.new(value + other.value, parts, @variable || other.variable?)
else
seconds = @parts.fetch(:seconds, 0) + other
Duration.new(value + other, @parts.merge(seconds: seconds), @variable)
end
end
What is interesting to me here is this @parts
variable. It seems that a Duration stores the value in two ways: as the number of seconds and as a parts
hash. While some of these are private to the class, fortunately for us, Ruby gives us some tools, such as #instance_variable_get
, to see the values being stored here:
> duration = (1.year + 5.months + 1.month + 3.days)
=> 1 year, 6 months, and 3 days
> duration.instance_variable_get :@parts
=> {:years=>1, :months=>6, :days=>3}
> duration.instance_variable_get :@value
=> 47594628
Therefore, Duration has more granularity that just X-number-of-seconds. Let’s see what happens when it is added to a Time
.
Time
Looking into the source of Rails’ Time calculations, we see that +
is actually aliased to this method:
def plus_with_duration(other) # :nodoc:
if ActiveSupport::Duration === other
other.since(self)
else
plus_without_duration(other)
end
end
alias_method :plus_without_duration, :+
alias_method :+, :plus_with_duration
We’re only concerned with Duration
right now, so it looks like our next stop is Duration#since
:
def since(time = ::Time.current)
sum(1, time)
end
Checking sum
in the same class, we find:
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
Now we’re getting somewhere. It seems that for seconds, minutes, and hours, Rails just adds the raw number of seconds to the Time. This makes sense because these values will always be the same regardless of when the code is called. For month
and year
, though, it uses Time#advance
. Looking up this method gives us the following:
# Provides precise Date calculations for years, months, and days. The +options+ parameter takes a hash with
# any of these keys: <tt>:years</tt>, <tt>:months</tt>, <tt>:weeks</tt>, <tt>:days</tt>.
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
Here, at last, we have our answer. >>
and +
are Ruby’s native Date
methods. >>
increments the month, while +
increments the day. The Ruby docs for >>
state that “When the same day does not exist for the corresponding month, the last day of the month is used instead”.
Conclusion
Rails’ date and time helpers are great. They save us from duplicating simple add-duration-to-time logic across our applications and make the code more readable. However, complex date manipulations are dangerous places full of edge-cases (and I didn’t even mention time zones in this article).
So, what is a Rails’ developer to do? Well, based on what I’ve learned here, this is my personal rule of thumb: use Rails’ helpers for single values (Time.current + 3.days
, etc.) or simple ranges (1.month...3.months
), but for anything more complicated, particularly calculations that involve mixing units (1.month + 3.days
), it is better to use the Date#advance
method (time.advance(months: 1, days: 3)
). This sacrifices a little on readability but ensures the result is consistent. It also helps to highlight the fact that there is more than just simple mathematical operations going on, hopefully so other developers are more mindful of the way days and months will be treated by this code.
Top comments (0)