Beginnless date ranges don't allow include?

1.7k views Asked by At

With ruby 2.7 beginless ranges were introduced. Now you can have:

(..5)
(5..10)
(10..)

With integers, .include? works as expected:

(..5).include?(6) # false
(..5).include?(5) # true
(..5).include?(2) # true
(..5).include?(-100) # true

The same does not work for date ranges however:

(..Date.tomorrow).include?(Date.today) # RangeError (cannot get the first element of beginless range)

Funnily, it works the other way round:

(Date.yesterday..).include?(Date.today) # true

And finally:

(Date.yesterday..).include?(Date.today - 2.days) # Seems to loop forever.

This is such a weird behaviour. All 3 cases bring a different result and only 1 of them actually works as intended.

I mean, I guess it would be understandable if we had a range that has some kind of "continious" logic to it, that it might be hard to check for inclusion. But relatively easy classes like Date should at least work. Date is almost like an Integer anyways. And even Float can do this, too, so I don't see why Date or DateTime shouldn't.

The usecase I have is that the database might give nil for a 2 dates that I'm querying. These are start and end dates that I want to use in a range, but I can't be sure that one of them might not be nil, which would be fine for my logic, but that would result in a beginningless range, which can't handle .include?.

I can easily make my usecase work with some manual ugly checks, but that's not the elegant ruby way. Am I missing something here? Or should this be a feature that's just not there yet?

2

There are 2 answers

1
Holger Just On

With Range#include?, you are actually iterating the range, comparing each element in the range whether it is equal to the tested element. Only with number ranges, this is optimized internally to behave as you apparently expect it to. To quote the docs:

Returns true if obj is an element of the range, false otherwise. If begin and end are numeric, comparison is done according to the magnitude of the values.

Thus, instead of Range#include? you likely want to use Range#cover? here which only checks the boundaries of the range (and which works the same as Range#include? only with numeric boundaries):

Returns true if obj is between the begin and end of the range.

This tests begin <= obj <= end when exclude_end? is false and begin <= obj < end when exclude_end? is true.

[...]

Returns false if the begin value of the range is larger than the end value. Also returns false if one of the internal calls to <=> returns nil (indicating the objects are not comparable).

With your examples, Range#cover? does the right thing:

(..Date.tomorrow).cover?(Date.today)
# => true

(Date.yesterday..).cover?(Date.today)
# => true

(Date.yesterday..).cover?(Date.today - 2.days)
#  => false
1
Todd A. Jacobs On

TL;DR

This is either a bug in how Date objects are compared in an endless Range, or a known issue with how certain iterators work with an endless Range. I provide an explanation and some workarounds below.

Analysis & Explanation

There are some surprising-but-documented behaviors of Ruby's beginless and endless Range objects. The documentation calls them "implementation details," and describes them as follows:

  • begin of beginless range and end of endless range are nil;
  • each of beginless range raises an exception;
  • each of endless range enumerates infinite sequence (may be useful in combination with Enumerable#take_while or similar methods);
  • (1..) and (1...) are not equal, although technically representing the same sequence.

As a result, you're somewhat at the mercy of how iteration is implemented for a given object type or method. Pragmatically, it seems that there are some optimizations for Integer ranges that allow for code like:

(1..).include? 999_999_999
#=> true

(1..).to_a
#=> RangeError (cannot convert endless range to an array)

to be performed (or to fail) quickly, but your particular code is (pragmatically speaking) attempting to reify infinity. As Date#yesterday is not a core Ruby method, it may also be an issue with how the Range is constructed by whatever mixin has monkeypatched your Date class. However, even when refactored to vanilla Ruby 2.7.1, ((Date.today - 1)..).include?(Date.today - 2) will hang.

Working Around the Behavior

Whether the behavior above is a bug or a design choice is a question for the Ruby Core Team. However, you can work around it very easily by checking bounds rather than iterating. If you must iterate, then don't try to iterate over infinity. For example:

require 'date'

def distant_future
  # 5 millenia from today
  Date.today + (365 * 5_000)
end

def yesterday
  Date.today - 1
end

def two_days_ago
  yesterday - 1
end

# slow, but returns in about 0m1.046s on my system
(yesterday .. distant_future).include? two_days_ago

By using something large but less than infinity as the end of your range, you allow the iteration to return. You can make this more performant in two ways:

  1. Shortening your date range, reducing the number of potential iterations.
  2. Checking for a date near the front of your range, requiring fewer iterations to match.

As an example, iterating over 1,825,000 days only to find you don't have a match takes noticeable time. On the other hand, the following returns almost instantly:

(two_days_ago .. distant_future).include? yesterday
#=> true

Every language has its share of bugs and rough edges. This appears to be one of them. Either way, I would recommend avoiding iteration over beginless/endless Date ranges in the interests of pragmatism.