Ruby: Create range of dates
Add duration support to Range#step
module RangeWithStepTime
def step(step_size = 1, &block)
return to_enum(:step, step_size) unless block_given?
# Defer to Range for steps other than durations on times
return super unless step_size.kind_of? ActiveSupport::Duration
# Advance through time using steps
time = self.begin
op = exclude_end? ? :< : :<=
while time.send(op, self.end)
yield time
time = step_size.parts.inject(time) { |t, (type, number)| t.advance(type => number) }
end
self
end
end
Range.prepend(RangeWithStepTime)
This approach affords
- Implicit support for preserving time-zone
- Adds duration support to the already-present
Range#step
method (no need for a sub-class, or convenience methods onObject
, though that was still fun) - Supports multi-part durations like
1.hour + 3.seconds
instep_size
This adds support for our duration to Range using the existing API. It allows you to use a regular range in the style that we expect to simply "just work".
# Now the question's invocation becomes even
# simpler and more flexible
step = 2.months + 4.days + 22.3.seconds
( Time.now .. 7.months.from_now ).step(step) do |time|
puts "It's #{time} (#{time.to_f})"
end
# It's 2013-10-17 13:25:07 +1100 (1381976707.275407)
# It's 2013-12-21 13:25:29 +1100 (1387592729.575407)
# It's 2014-02-25 13:25:51 +1100 (1393295151.8754072)
# It's 2014-04-29 13:26:14 +1000 (1398741974.1754072)
The previous approach
...was to add an #every
using a DateRange < Range
class + DateRange
"constructor" on Object
, then convert the times to integers internally, stepping through them in step
seconds. This didn't work for time zones originally. Support for time zones was added but then another issue was found with the fact some step durations are dynamic (eg 1.month
).
Reading Rubinius' Range
implementation it became clear how someone might add support for ActiveSupport::Duration
; so the approach was rewritten. Much thanks to Dan Nguyen for the #advance
tip and debugging around this, and to Rubinius' implementation of Range#step
for being beautifully written :D
Update 2015-08-14
This patch was not merged into Rails/ActiveSupport. You should stick to
for
loops using#advance
. If you're gettingcan't iterate from Time
or something like that, then use this patch, or just avoid usingRange
.Updated patch to reflect Rails 4+
prepend
style overalias_method_chain
.
Using @CaptainPete's base, I modified it to use the ActiveSupport::DateTime#advance
call. The difference comes into effect when the time intervals are non-uniform, such as `:month" and ":year"
require 'active_support/all'
class RailsDateRange < Range
# step is similar to DateTime#advance argument
def every(step, &block)
c_time = self.begin.to_datetime
finish_time = self.end.to_datetime
foo_compare = self.exclude_end? ? :< : :<=
arr = []
while c_time.send( foo_compare, finish_time) do
arr << c_time
c_time = c_time.advance(step)
end
return arr
end
end
# Convenience method
def RailsDateRange(range)
RailsDateRange.new(range.begin, range.end, range.exclude_end?)
end
My method also returns an Array
. For comparison's sake, I altered @CaptainPete's answer to also return an array:
By hour
RailsDateRange((4.years.ago)..Time.now).every(years: 1)
=> [Tue, 13 Oct 2009 11:30:07 -0400,
Wed, 13 Oct 2010 11:30:07 -0400,
Thu, 13 Oct 2011 11:30:07 -0400,
Sat, 13 Oct 2012 11:30:07 -0400,
Sun, 13 Oct 2013 11:30:07 -0400]
DateRange((4.years.ago)..Time.now).every(1.year)
=> [2009-10-13 11:30:07 -0400,
2010-10-13 17:30:07 -0400,
2011-10-13 23:30:07 -0400,
2012-10-13 05:30:07 -0400,
2013-10-13 11:30:07 -0400]
By month
RailsDateRange((5.months.ago)..Time.now).every(months: 1)
=> [Mon, 13 May 2013 11:31:55 -0400,
Thu, 13 Jun 2013 11:31:55 -0400,
Sat, 13 Jul 2013 11:31:55 -0400,
Tue, 13 Aug 2013 11:31:55 -0400,
Fri, 13 Sep 2013 11:31:55 -0400,
Sun, 13 Oct 2013 11:31:55 -0400]
DateRange((5.months.ago)..Time.now).every(1.month)
=> [2013-05-13 11:31:55 -0400,
2013-06-12 11:31:55 -0400,
2013-07-12 11:31:55 -0400,
2013-08-11 11:31:55 -0400,
2013-09-10 11:31:55 -0400,
2013-10-10 11:31:55 -0400]
By year
RailsDateRange((4.years.ago)..Time.now).every(years: 1)
=> [Tue, 13 Oct 2009 11:30:07 -0400,
Wed, 13 Oct 2010 11:30:07 -0400,
Thu, 13 Oct 2011 11:30:07 -0400,
Sat, 13 Oct 2012 11:30:07 -0400,
Sun, 13 Oct 2013 11:30:07 -0400]
DateRange((4.years.ago)..Time.now).every(1.year)
=> [2009-10-13 11:30:07 -0400,
2010-10-13 17:30:07 -0400,
2011-10-13 23:30:07 -0400,
2012-10-13 05:30:07 -0400,
2013-10-13 11:30:07 -0400]
No rounding errors, a Range
calls the .succ
method to enumerate the sequence, which is not what you want.
Not a one-liner but, a short helper function will suffice:
def datetime_sequence(start, stop, step)
dates = [start]
while dates.last < (stop - step)
dates << (dates.last + step)
end
return dates
end
datetime_sequence(DateTime.now, DateTime.now + 1.day, 1.hour)
# [Mon, 30 Sep 2013 08:28:38 -0400, Mon, 30 Sep 2013 09:28:38 -0400, ...]
Note, however, this could be wildly inefficient memory-wise for large ranges.
Alternatively, you can use seconds since the epoch:
start = DateTime.now
stop = DateTime.now + 1.day
(start.to_i..stop.to_i).step(1.hour)
# => #<Enumerator: 1380545483..1380631883:step(3600 seconds)>
You'll have a range of integers, but you can convert back to a DateTime
easily:
Time.at(i).to_datetime