Skip to content

Instantly share code, notes, and snippets.

@damien
Last active July 15, 2016 14:15
Show Gist options
  • Save damien/56602b706f0cfff7b92b2e6b4af50b98 to your computer and use it in GitHub Desktop.
Save damien/56602b706f0cfff7b92b2e6b4af50b98 to your computer and use it in GitHub Desktop.
Quick dive into how Rails/ActiveSupport does Date, Time, and DateTime comparisons

Date and Time comparisons in ActiveSupport

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 for Time#<=>. 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 of DateTime and compare that to the value of other (in this case 2.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:

  1. If we're comparing one Time to another, use ruby's regular comparison logic.
  2. If we're not comparing one time to another, coerce our time to an instance of DateTime and compare that datetime to other
  • 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

Conclusion

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment