Question: How do we safely compare a date to an unknown value within a Rails application?
Example: params[:date_field] > 2.years.ago
Doing a bit of digging, it looks like 2.years.ago
returns an instance of ActiveSupport::TimeWithZone
.
In ruby, whenever you do a comparison of any sort ruby will follow the rules outlined in the Comparable
module of stdlib.
ActiveSupport::TimeWithZone
will create a instance of Time
in a UTC time zone when doing comparisons with ActiveSupport::TimeWithZone#<=>
.
This means that as long as you know that params[:date_field]
is comparable to Time, you shouldn't need to care about anything else.
Going a level deeper, it seems Time#<=>
is overridden by ActiveSupport to use ActiveSupport::CoreExtensions::Time::Calculations#compare_with_coercion.
The source of that method looks something like this:
[32] pry(main)> show-source (2.years.ago).utc.<=>
From: /usr/local/var/rbenv/versions/2.2.2/lib/ruby/gems/2.2.0/gems/activesupport-4.2.6/lib/active_support/core_ext/time/calculations.rb @ line 251:
Owner: Time
Visibility: public
Number of lines: 8
def compare_with_coercion(other)
# we're avoiding Time#to_datetime cause it's expensive
if other.is_a?(Time)
compare_without_coercion(other.to_time)
else
to_datetime <=> other
end
end
A few things here to note:
- This method already checks if the thing we're comparing against is an instance of
Time
- In this context,
compare_without_coercion
is an alias forTime#<=>
. This means Rails defaults to ruby's standard comparison logic whenever we're comparing two times to each other - In the case where we are not comparing two times, we convert self (in this case
business_started_date
) to an instance ofDateTime
and compare that to the value ofother
(in this case2.years.ago
).
This has been a deeper dive than I was expecting, but it looks like we're at the end of this rabbit hole.
The comparison logic for DateTime
looks like this:
[45] pry(main)> show-source 2.years.ago.utc.to_datetime.<=>
From: /usr/local/var/rbenv/versions/2.2.2/lib/ruby/gems/2.2.0/gems/activesupport-4.2.6/lib/active_support/core_ext/date_time/calculations.rb @ line 163:
Owner: DateTime
Visibility: public
Number of lines: 9
def <=>(other)
if other.kind_of?(Infinity)
super
elsif other.respond_to? :to_datetime
super other.to_datetime rescue nil
else
nil
end
end
I'm going to ignore the Infinity
check, as I have no idea what that's about.
What we do care about is the remaining checks.
Here, Rails attempts to coerce the other object to an instance of DateTime
.
If it can do so, it compares self (a DateTime
) to the other DateTime using ruby's default comparison logic.
If it can't, we get nil
.
At the end of the day, the steps Rails takes to safely compare times looks like this:
- If we're comparing one
Time
to another, use ruby's regular comparison logic. - If we're not comparing one time to another, coerce our time to an instance of
DateTime
and compare that datetime toother
- If other can be coerced into an instance of
DateTime
, do so and compare our datetime to the other datetime using ruby's regular comparison logic. - If other cannot be coerced, return
nil
If you really have no idea what the value you're trying to compare to will be,
the safest thing you can do is to check to see that you only ever compare instances of Date
, Time
, and DateTime
with each other
and nothing else.
Attempting to coerce other times of objects into times or datetimes can result in exceptions and are not fullproof.
Date.parse
and Time.parse
are known for returning all sorts of strange results when fed random strings,
so we don't really have a safe way of coercing strings to dates without making guarantees
about the format of the strings we want to coerce.