[Feature proposal] ActiveSupport DateRange

The Date and Time calculations in Rails have made working with dates and times much easier. Nobody likes doing these complex calculations by hand and the Rails API like 5.days.ago is a great abstraction!

In our codebase, we work a lot with date ranges. The ranges are used to filter data sets and present reports. To make working with date ranges easier, we have written a DateRange value object that handles parsing ranges and calculations on ranges. The object is a Range itself, so it is natively supported by ActiveRecord and SQL BETWEEN queries.

We’d love to contribute the DateRange implementation to Rails core. The DateAndTime::Calculations::all_* methods could return a DateRange. An instance would enable:

  • Calculations like DateRange#next and DateRange#previous
  • Easy checks like DateRange#month? or DateRange#year?
  • Working with granularities: DateRange#in_groups_of(:month)
  • DateRange#to_param to use the ranges in params. Can serialize to both a static range like 202101..202103 or a relative range like this_month.
  • DateRange#humanize to print the date range: for example january 2021, Quarter 1 2021 or 2021-01-05 - 2021-02-06
  • Parsing of ranges from strings (like params): DateRange.parse("202101..202103")
  • Class methods to get fast access to ranges: DateRange.prev_month

Some examples of how we use DateRange:

  • When we need to export all data from a period and want columns with months:
DateRange.prev_year.in_groups_of(:months).each {|month| ... }
  • When we are rendering a report for a certain period and want to have links to the next and previous period:
link_to(report_path(period: @period.next), "next")
  • When we want to query data for a provided range in the params:
SomeModel.where(date: DateRange.parse(params[:period]))

I’d love to hear if this feature is interesting enough to be included in Rails core so we can start to rewrite our code to fit into ActiveSupport. Thanks!

7 Likes

I think it’s pretty cool. Thank you!

@rafaelfranca @jonathanhefner You are both actively working on ActiveSupport, any thoughts on this?

1 Like

Generally speaking, new Active Support features require strong evidence of usefulness. Perhaps you could elaborate more on how a DateRange is more useful than a Range of Dates?

Also, some of the proposed methods seem problematic:

  • DateRange#next returns a next what? I see @period.next in your example, but how is a “period” defined? The same number of days or months or years as the last period? Each definition would produce different results in various corner cases. For example, a 30-day billing cycle is always 30 days long, but can be less than, equal to, or greater than 1 month long. On the other hand, a fiscal quarter is always 3 months, but can be 90, 91, or 92 days long.

  • A predicate like DateRange#month? could be interpreted in multiple ways. For example, “begins on the first day and ends on the last day of the same month of the same year” (e.g. “does this range comprise the month of December?”) OR “begins on a day and ends on the same day of the next month”. Both of these interpretations seem specialized. The latter interpretation additionally raises the issue of inclusive vs exclusive ranges.

  • I’m not sure I understand what DateRange#in_groups_of(:month) would return. Would it be the same as or similar to (date1..date2).group_by(&:month)? Also, Active Support already provides an in_groups_of method with different semantics, albeit for Arrays.

  • Regarding printing, Active Support provides a configurable mechanism that would allow you to write something like (date1..date2).to_s(:human).

2 Likes

Thanks for your critical notes on my proposal. I completely agree that additions to a framework like ActiveSupport should be very useful for many users. I still believe DateRange has its value above a Range of Dates.

Let me first clarify some of the methods you have questions about:

DateRange#next and DateRange#previous always return a new DateRange with the same length as the current. So a 10 days range would return the consecutive 10 days as next. The method becomes more special when you are dealing with months, quarters and years. Because these special periods have different lengths in days, you need to take this into account. A full month of 30 days, will return the consecutive full month of 31 days (or 29, 28 or 30 depending on the situation). So you get a full month.

I get that this might be confusing and in the case of a strict 30-day billing cycle, the next method won’t be of use. Maybe an option to get either a full month or a strict number of days might be an worth considering.

Good point. Maybe DateRange#month? should be renamed to DateRange#one_month?. This method returns whether the range is one full month. We already use DateRange#full_month? method, which returns whether the range is one or more full months (first date is first of month, last date is last of same or another month).

(Date.new(2021, 1, 1)..Date.new(2021, 12, 31)).group_by(&:month) would return an array with arrays for each group. Besides that, it wouldn’t work when the range involves several years, because Date#month returns the month number.

DateRange.parse("202101..202112").in_groups_of(:month) returns an array with 12 items, all of them are DateRange objects. The first item is January, second February, etc. Because we return DateRange objects, they are easily used in queries and URL’s.

Renaming DateRange#in_groups_of to prevent confusion with Array#in_groups_of might be a good idea. I tried to keep the semantics as similar as possible, but the methods don’t work exactly the same.

I didn’t know the Range#to_s of ActiveSupport. DateRange#humanize can be replaced by a to_s(format) implementation. We would effectively implement a very advanced format, because we take into account many variants. Depending on the format and period, we return things like “This year”, “2021”, “Q1 2021”, “Q1 - Q2 2021”, “Q1 2021 - Q2 2022”, etc.


Now for the general question: is a DateRange more useful than a Range of Dates:

Working with periods in software can be difficult. You need to reason about many exceptions in for example the length of months, quarters and years. By having a dedicated object for periods, you can easily reason about the period. This makes the code more readable and less lengthy:

  • If you want to know if the range is one or more full months:
range = Date.new(2021, 1, 1)..Date.new(2021, 1, 31)
range.begin == range.begin.at_beginning_of_month && range.end == range.end.at_end_of_month

date_range = DateRange.new(Date.new(2021, 1, 1), Date.new(2021, 1, 31))
date_range.full_month?
  • If you want to know if the range is exactly one month:
range = Date.new(2021, 1, 1)..Date.new(2021, 1, 31)
range.begin.month == range.end.month && range.begin == range.begin.at_beginning_of_month && range.end == range.end.at_end_of_month 

date_range = DateRange.new(Date.new(2021, 1, 1), Date.new(2021, 1, 31))
date_range.one_month?
  • Having a dedicated way to parameterize and parse the DateRange saves code too. Besides support for explicit ranges in params, having relative params like this_year makes it very easy to have bookmarkable URL’s for your users to always open a certain period relative to today.
range = Date.new(2021, 1, 1)..Date.new(2021, 1, 31)
param = range.to_param # => "2021-01-01..2021-1-31"
first, last = param.split("..")
parsed_range = Range.new(Date.parse(first), Date.parse(last))

date_range = DateRange.new(Date.new(2021, 1, 1), Date.new(2021, 1, 31))
param = date_range.to_param # => "202101..202101"
parsed_range = DateRange.parse(param)
  • Calculations like the number of months in a range a easier:
range = Date.new(2021, 1, 1)..Date.new(2021, 1, 31)
((range.end.year - range.begin.year) * 12) + (range.end.month - range.begin.month + 1)

date_range = DateRange.new(Date.new(2021, 1, 1), Date.new(2021, 1, 31))
date_range.months

Hopefully this takes away some of your questions, thanks again!

1 Like

After talking with DHH about my proposal, he suggested to first release this as a gem. This allows others to use the object and provide feedback on the API. Better have a stable API before merging this into Rails core.

3 Likes

How does PostgresQL and other databases handle DateRange?

.where(date: DateRange.this_year) converts to date BETWEEN begin AND end, this is default ActiveRecord behaviour for ranges in where clauses.

Postgres has:

  • tsrange — Range of timestamp without time zone
  • tstzrange — Range of timestamp with time zone
  • daterange — Range of date

Just like any other vendor specific types you can already use them today through the ActiveRecord::Attributes api. It would look rougly like:

class DateRangeType < ActiveRecord::Type::Value
  def cast(value)
     # @todo cast from user input
  end

  def serialize(value)
     "[#{value.begin}, #{value.end}]::daterange"
  end
  
  def deserialize(value)
     # @todo parse:
     # "['2000-12-31', '2001-12-31']"
     # "['2000-12-31', '2001-12-31')"
     # "('2000-12-31', '2001-12-31]"
     # "('2000-12-31', '2001-12-31')"
  end
end
class Event < ActiveRecord::Base
   attribute :dates, DateRangeType 
end

Like other vendor specific implementations I would agree that this belongs in a separate gem.