(Ruby) Gotcha: Comparing Time
The Gotcha
After saving an instance of a model to the database, it's Time
fields (or ActiveSupport::TimeWithZone
fields) may no longer have the same values.
Example
In the following example, the instance fetched from the database does not have the same value for time
that create
saved to the database:
time = Time.now
original_instance = Model.create(id: 1, time: time)
saved_instance = Model.find_by(id: 1)
original_instance.time == saved_instance.time # false?!
original_instance.time.eql? saved_instance.time # false?!
puts original_instance.time
puts saved_instance.time
Even more confusing, the puts
output for both times looks exactly the same:
2021-03-24 02:17:23 +0000
2021-03-24 02:17:23 +0000
This is especially mysterious if RSpec tests fail while comparing times that you know should be the same:
expected: 2021-03-24 02:17:23.904144615 +0000
got: 2021-03-24 02:17:23.904144000 +0000
(compared using eql?)
Diff:
@@ -1 +1 @@
-Wed, 24 Mar 2021 02:17:23.904144615 UTC +00:00
+Wed, 24 Mar 2021 02:17:23.904144000 UTC +00:00
Why it Happens
The database doesn't store the same amont of precision as Ruby.
In the example above, the failing RSpec test reveals that the database only has microsecond precision while Ruby has nanosecond precision. Thus, when the instance is fetched from the database, those three extra digits of precision can't be retrieved because they were never saved to the database in the first place.
The Fix
Solution 1: Ignore excess precision in comparisons
If only the seconds need to match, convert both times to integers and compare those instead. If the microseconds need to match too, convert to float instead:
ruby_time.to_i == database_time.to_i # true
ruby_time.to_f == database_time.to_f # true
For an elegant implementation, add a custom matcher to spec_helper.rb. This assumes that the time comparisons are only needed for RSpec tests. It might look like this:
RSpec::Matchers.define :be_same_second_as do |expected|
match do |actual|
actual.to_i.eql? expected.to_i
end
end
###################################################
it "confirms two times represent the same second" do
expect(database_time).to be_same_second_as(ruby_time)
end
This solution is my favorite. It's just quick and easy.
Solution 2: Use the be_within
matcher
If up to one second between times is tolerable, RSpec already has a matcher for the job:
expect(database_time).to be_within(1.second).of(ruby_time)
This solution is clean but doesn't handle windows smaller than one second. Also, it only works for time comparisons in RSpec tests.
Solution 3: Increase the database precision
Change the database time columns to have a nanosecond level of precision.
This is nice if you don't want to continuously have to remember potential issues whenever you're comparing time in Ruby code. Of course, it comes at a high cost. Updating columns requires a database migration for existing tables and will take up more disk space. Also, some databases (e.g. MySQL 5.7) don't support nanosecond levels of precision.
Unless nanosecond granularity is a requirement, this solution is overkill.
Solution 4: Truncate Ruby's precision
If fractional-second precision isn't necessary, completely remove it when a Time
instance is created. Here are two ways to truncate:
time = Time.now.round
puts time.nsec # 0
require 'active_support/time'
time = Time.now.change(nsec: 0)
puts time.nsec # 0
This solution feels hackier than the others, but it certainly does the job.
Conclusion
I wish these fixes could solve all your time problems, but this is time we're talking about. In fact, while doing research for this article, I learned about another Ruby developer who shared his struggles with time precision differences between operating systems! Go read it. It's fantastic.
So yeah, maybe I only helped solve one time problem. But at least that's one fewer problems to debug!