The Mysterious Time-to-DateTime Conversion: Unraveling Ruby’s Unexpected Behavior
I recently stumbled upon a perplexing discrepancy while converting Time
to DateTime
using the #to_datetime
method. Intrigued by this unexpected behavior, I delved into an investigation to unravel its root cause. I’ll share my findings in this blog post with you, as I believe it’s worth your attention.
Introduction
I’m currently engaged in the challenging task of upgrading a massive Ruby on Rails monolith. This unique experience has allowed me to dive into unfamiliar territory, learn new things, and engage with numerous stakeholders.
Amid fixing a failing spec on the new Rails version, I stumbled upon the following behavior:
# ruby 2.7.7p221 (2022-11-24 revision 168ec2b1e5) [arm64-darwin22]
time = Time.new(1,1,1,0)
=> 0001-01-01 00:00:00 +0100
time.to_datetime
=> #<DateTime: 0001-01-03T00:00:00+01:00 ((1721425j,82800s,0n),+3600s,2299161j)>
I couldn’t help but wonder why there was a 2-day shift when converting Time
to DateTime
. Although I found a quick fix for my specs, the behavior continued to intrigue me, compelling me to dig deeper.
Investigation
Initially, I wasn’t sure where to begin. I started by refreshing my knowledge of the differences between Time
and DateTime
in Ruby’s documentation:
Time
is an abstraction of dates and times.Time
is stored internally as the number of seconds with fraction since the Epoch, January 1, 1970 00:00 UTC.
…
Since Ruby 1.9.2,Time
implementation uses a signed 63 bit integer, Bignum orRational
. The integer is a number of nanoseconds since the Epoch which can represent 1823-11-12 to 2116-02-20. When Bignum orRational
is used (before 1823, after 2116, under nanosecond),Time
works slower as when integer is used.
A subclass of
Date
that easily handles date, hour, minute, second, and offset.DateTime
does not consider any leap seconds, does not track any summer time rules.
I quickly scrolled those documentations, then googled a bit, but, unfortunately, I couldn’t find anything related to the situation.
Therefore, I decided to go the “hard way” and opened a Ruby source code.
static VALUE
time_to_datetime(VALUE self)
{
VALUE y, sf, nth, ret;
int ry, m, d, h, min, s, of;
y = f_year(self);
m = FIX2INT(f_mon(self));
d = FIX2INT(f_mday(self));
h = FIX2INT(f_hour(self));
min = FIX2INT(f_min(self));
s = FIX2INT(f_sec(self));
if (s == 60)
s = 59;
sf = sec_to_ns(f_subsec(self));
of = FIX2INT(f_utc_offset(self));
decode_year(y, -1, &nth, &ry);
ret = d_complex_new_internal(cDateTime,
nth, 0,
0, sf,
of, GREGORIAN,
ry, m, d,
h, min, s,
HAVE_CIVIL | HAVE_TIME);
{
get_d1(ret);
set_sg(dat, DEFAULT_SG);
}
return ret;
}
From top to bottom, a few things got my attention:
decode_year
— It deals with year-related operations differently from other attributes like month and day, although our issue pertains more to the “day” aspect.GREGORIAN
— This keyword provides a clue for the following line of investigation.DEFAULT_SG
— It is defined as#define DEFAULT_SG ITALY
.
Armed with these findings, I revisited the Time
and DateTime
documentation and finally uncovered an explanation that shed light on the reason behind the behavior.
An optional argument, the day of calendar reform (
start
), denotes a Julian day number, which should be 2298874 to 2426355 or negative/positive infinity. The default value isDate::ITALY
(2299161=1582-10-15).
…
Since Ruby’s Time class implements a proleptic Gregorian calendar and has no concept of calendar reform, there’s no way to express this with Time objects.
There is also an excellent explanation of when to use what.
P.S. It’s important to say that this paragraph was just a few blocks from the beginning, meaning my eyes simply overlooked it when I was “very quickly” scrolling through the page.
Conclusion
Dealing with time, especially in the context of time zones, is always challenging and prone to bugs. This investigation has highlighted an additional consideration before claiming that our code functions as expected. We can make informed decisions and write more robust code by understanding the intricacies of Ruby’s Time
and DateTime
conversions.
As for the conversion, it can be done with .gregorian
to have the expected value.
require 'time'
time = Time.new(1,1,1,0)
=> 0001-01-01 00:00:00 +0100
time.to_datetime
=> #<DateTime: 0001-01-03T00:00:00+01:00 ((1721425j,82800s,0n),+3600s,2299161j)>
time.to_datetime.gregorian
=> #<DateTime: 0001-01-01T00:00:00+01:00 ((1721425j,82800s,0n),+3600s,-Infj)>
Thanks for reading.
Please consider subscribing!