[Feature/change proposal] Handling of Postgres's unbounded temporal ranges in ActiveRecord

Postgres temporal ranges (i.e., tsrange, daterange, etc.) are reflected as Ruby ranges. If the range is unbounded on the left or right, ActiveRecord prefers -Float::INFINITY or Float::INFINITY, respectively. This is not ideal for at least two reasons.

First, because Time and Float are different types, comparisons do not work as you’d like. We have the awkward situation that, while time < float is well-defined (by ActiveSupport), float < time is not. More importantly #cover? does not behave as expected:

((-::Float::INFINITY)...(::Float::INFINITY)).cover?(Time.now)
# => false

Insofar as the range is meant to represent all of time, this is just wrong.

Secondly, this representation is an awkward fit for Postgres’s own representation. In Postgres, an infinite value (Infinity or -Infinity) can be used as a bound in a temporal range. Alternatively, the range can lack one or both bounds. These, however, have subtly different meanings:

dev=# select '(,)'::tsrange @> 'Infinity'::timestamp;
 ?column? 
----------
 t
(1 row)

dev=# select '(-Infinity, Infinity)'::tsrange @> 'Infinity'::timestamp;
 ?column? 
----------
 f
(1 row)

That is to say, Postgres treats infinite values as normal cardinals (sort of?), rather than limits, for the purpose of comparison. Ruby does not:

((-::Float::INFINITY)..(::Float::INFINITY)).cover?(Float::INFINITY)
# => true

Now, Ruby’s ranges have an analog to the Postgres representation where a bound is missing (rather than given an infinite value). In Ruby, a range can use nil as either bound, which indicates that the range is unbounded on that side. nil-bounded ranges handle the #cover? case well:

(nil...nil).cover?(Time.now)
# => true

Of course, if you tried to extract the (nil) bound from the range and compare it with a time, you’d have a problem, but if you’re working with Ruby ranges you already need to be aware of that case.

Now, the interesting thing is that ActiveRecord prefers the infinite value-bounded range for its Ruby representation, but it prefers the unbounded range for its Postgres representation. I mean, if you use AR to insert Range.new(nil, nil) or Range.new(-Float::INFINITY, Float::INFINITY) into a tsrange column, it will show up in the database as [,]::tsrange. When you pull that same value out of the database, it will be represented in Ruby as Range.new(-Float::INFINITY, Float::INFINITY).

It seems to me that:

  • The preferred representation in PG is the one that does not use infinite values. (This is already what AR prefers today.)
  • (Perhaps controversial?) The preferred value in Ruby is the nil-bounded range, rather than the float-bounded one.
  • Those two representations are better reflections of each other than any alternative.

So what I’m proposing is that AR should use nil to represent boundless ranges in Ruby. Since this is a backwards-incompatible change, it would need to be opt-in, but to start with, I’m curious if this representation was considered and rejected for reasons I haven’t thought of.