Skip to content

Commit 8deaaf4

Browse files
Support Duration in Date.range/3 (#14172)
1 parent aea54fc commit 8deaaf4

File tree

2 files changed

+101
-10
lines changed

2 files changed

+101
-10
lines changed

lib/elixir/lib/calendar/date.ex

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ defmodule Date do
6969
calendar: Calendar.calendar()
7070
}
7171

72+
@typedoc "A duration unit expressed as a tuple."
73+
@typedoc since: "1.19.0"
74+
@type duration_unit_pair ::
75+
{:year, integer} | {:month, integer} | {:week, integer} | {:day, integer}
76+
7277
@doc """
7378
Returns a range of dates.
7479
@@ -84,6 +89,20 @@ defmodule Date do
8489
iex> Date.range(~D[1999-01-01], ~D[2000-01-01])
8590
Date.range(~D[1999-01-01], ~D[2000-01-01])
8691
92+
A range may also be built from a `Date` and a `Duration`
93+
(also expressed as a keyword list of `t:duration_unit_pair/0`):
94+
95+
iex> Date.range(~D[1999-01-01], Duration.new!(year: 1))
96+
Date.range(~D[1999-01-01], ~D[2000-01-01])
97+
iex> Date.range(~D[1999-01-01], year: 1)
98+
Date.range(~D[1999-01-01], ~D[2000-01-01])
99+
100+
> #### Durations {: .warning}
101+
>
102+
> Support for expressing `last` as a [`Duration`](`t:Duration.t/0`) or
103+
> keyword list of `t:duration_unit_pair/0`s was introduced in
104+
> v1.19.0.
105+
87106
A range of dates implements the `Enumerable` protocol, which means
88107
functions in the `Enum` module can be used to work with
89108
ranges:
@@ -100,7 +119,11 @@ defmodule Date do
100119
101120
"""
102121
@doc since: "1.5.0"
103-
@spec range(Calendar.date(), Calendar.date()) :: Date.Range.t()
122+
@spec range(
123+
first :: Calendar.date(),
124+
last_or_duration :: Calendar.date() | Duration.t() | [duration_unit_pair]
125+
) ::
126+
Date.Range.t()
104127
def range(%{calendar: calendar} = first, %{calendar: calendar} = last) do
105128
{first_days, _} = to_iso_days(first)
106129
{last_days, _} = to_iso_days(last)
@@ -123,6 +146,16 @@ defmodule Date do
123146
raise ArgumentError, "both dates must have matching calendars"
124147
end
125148

149+
def range(%{calendar: _} = first, %Duration{} = duration) do
150+
last = shift(first, duration)
151+
range(first, last)
152+
end
153+
154+
def range(%{calendar: _} = first, duration) when is_list(duration) do
155+
last = shift(first, duration)
156+
range(first, last)
157+
end
158+
126159
@doc """
127160
Returns a range of dates with a step.
128161
@@ -140,8 +173,11 @@ defmodule Date do
140173
141174
"""
142175
@doc since: "1.12.0"
143-
@spec range(Calendar.date(), Calendar.date(), step :: pos_integer | neg_integer) ::
144-
Date.Range.t()
176+
@spec range(
177+
first :: Calendar.date(),
178+
last_or_duration :: Calendar.date() | Duration.t() | [duration_unit_pair],
179+
step :: pos_integer | neg_integer
180+
) :: Date.Range.t()
145181
def range(%{calendar: calendar} = first, %{calendar: calendar} = last, step)
146182
when is_integer(step) and step != 0 do
147183
{first_days, _} = to_iso_days(first)
@@ -159,6 +195,24 @@ defmodule Date do
159195
"non-zero integer, got: #{inspect(first)}, #{inspect(last)}, #{step}"
160196
end
161197

198+
def range(%{calendar: _} = first, %Duration{} = duration, step)
199+
when is_integer(step) and step != 0 do
200+
last = shift(first, duration)
201+
range(first, last, step)
202+
end
203+
204+
def range(%{calendar: _} = first, duration, step)
205+
when is_list(duration) and is_integer(step) and step != 0 do
206+
last = shift(first, duration)
207+
range(first, last, step)
208+
end
209+
210+
def range(%{calendar: _} = first, last, step) do
211+
raise ArgumentError,
212+
"expected a date or duration as second argument and the step must be a " <>
213+
"non-zero integer, got: #{inspect(first)}, #{inspect(last)}, #{step}"
214+
end
215+
162216
defp range(first, first_days, last, last_days, calendar, step) do
163217
%Date.Range{
164218
first: %Date{calendar: calendar, year: first.year, month: first.month, day: first.day},
@@ -795,8 +849,7 @@ defmodule Date do
795849
796850
"""
797851
@doc since: "1.17.0"
798-
@spec shift(Calendar.date(), Duration.t() | [unit_pair]) :: t
799-
when unit_pair: {:year, integer} | {:month, integer} | {:week, integer} | {:day, integer}
852+
@spec shift(Calendar.date(), Duration.t() | [duration_unit_pair]) :: t
800853
def shift(%{calendar: calendar} = date, duration) do
801854
%{year: year, month: month, day: day} = date
802855
{year, month, day} = calendar.shift_date(year, month, day, __duration__!(duration))

lib/elixir/test/elixir/calendar/date_range_test.exs

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,12 @@ defmodule Date.RangeTest do
66

77
@asc_range Date.range(~D[2000-01-01], ~D[2001-01-01])
88
@asc_range_2 Date.range(~D[2000-01-01], ~D[2001-01-01], 2)
9+
@asc_range_duration Date.range(~D[2000-01-01], Duration.new!(year: 1))
10+
@asc_range_duration_2 Date.range(~D[2000-01-01], Duration.new!(year: 1), 2)
911
@desc_range Date.range(~D[2001-01-01], ~D[2000-01-01], -1)
1012
@desc_range_2 Date.range(~D[2001-01-01], ~D[2000-01-01], -2)
13+
@desc_range_duration Date.range(~D[2001-01-01], Duration.new!(year: -1))
14+
@desc_range_duration_2 Date.range(~D[2001-01-01], Duration.new!(year: -1), 2)
1115
@empty_range Date.range(~D[2001-01-01], ~D[2000-01-01], 1)
1216

1317
describe "Enum.member?/2" do
@@ -20,6 +24,9 @@ defmodule Date.RangeTest do
2024

2125
assert Enum.member?(@asc_range_2, ~D[2000-01-03])
2226
refute Enum.member?(@asc_range_2, ~D[2000-01-02])
27+
28+
assert Enum.member?(@asc_range_duration, ~D[2000-01-03])
29+
refute Enum.member?(@asc_range_duration_2, ~D[2000-01-02])
2330
end
2431

2532
test "for descending range" do
@@ -31,6 +38,9 @@ defmodule Date.RangeTest do
3138

3239
assert Enum.member?(@desc_range_2, ~D[2000-12-30])
3340
refute Enum.member?(@desc_range_2, ~D[2000-12-29])
41+
42+
assert Enum.member?(@desc_range_duration, ~D[2000-12-30])
43+
refute Enum.member?(@desc_range_duration_2, ~D[2000-12-29])
3444
end
3545

3646
test "empty range" do
@@ -109,6 +119,30 @@ defmodule Date.RangeTest do
109119
assert Enum.to_list(range) == [~D[2000-01-01], ~D[2000-01-03]]
110120
end
111121

122+
test "works with durations" do
123+
range = Date.range(~D[2000-01-01], Duration.new!(day: 1))
124+
assert range.first == ~D[2000-01-01]
125+
assert range.last == ~D[2000-01-02]
126+
assert Enum.to_list(range) == [~D[2000-01-01], ~D[2000-01-02]]
127+
128+
range = Date.range(~D[2000-01-01], Duration.new!(day: 2), 2)
129+
assert range.first == ~D[2000-01-01]
130+
assert range.last == ~D[2000-01-03]
131+
assert Enum.to_list(range) == [~D[2000-01-01], ~D[2000-01-03]]
132+
end
133+
134+
test "accepts durations as keyword list" do
135+
range = Date.range(~D[2000-01-01], day: 1)
136+
assert range.first == ~D[2000-01-01]
137+
assert range.last == ~D[2000-01-02]
138+
assert Enum.to_list(range) == [~D[2000-01-01], ~D[2000-01-02]]
139+
140+
range = Date.range(~D[2000-01-01], [day: 2], 2)
141+
assert range.first == ~D[2000-01-01]
142+
assert range.last == ~D[2000-01-03]
143+
assert Enum.to_list(range) == [~D[2000-01-01], ~D[2000-01-03]]
144+
end
145+
112146
test "both dates must have matching calendars" do
113147
first = ~D[2000-01-01]
114148
last = Calendar.Holocene.date(12001, 1, 1)
@@ -129,18 +163,22 @@ defmodule Date.RangeTest do
129163
end
130164

131165
test "step is a non-zero integer" do
132-
step = 1.0
133166
message = ~r"the step must be a non-zero integer"
134167

135168
assert_raise ArgumentError, message, fn ->
136-
Date.range(~D[2000-01-01], ~D[2000-01-31], step)
169+
Date.range(~D[2000-01-01], ~D[2000-01-31], 1.0)
137170
end
138171

139-
step = 0
140-
message = ~r"the step must be a non-zero integer"
172+
assert_raise ArgumentError, message, fn ->
173+
Date.range(~D[2000-01-01], [month: 1], 1.0)
174+
end
175+
176+
assert_raise ArgumentError, message, fn ->
177+
Date.range(~D[2000-01-01], ~D[2000-01-31], 0)
178+
end
141179

142180
assert_raise ArgumentError, message, fn ->
143-
Date.range(~D[2000-01-01], ~D[2000-01-31], step)
181+
Date.range(~D[2000-01-01], [month: 1], 0)
144182
end
145183
end
146184

0 commit comments

Comments
 (0)