Skip to content

Commit 09bf540

Browse files
authored
Add step to Date.Range (#10816)
1 parent 9b44d95 commit 09bf540

File tree

3 files changed

+199
-65
lines changed

3 files changed

+199
-65
lines changed

lib/elixir/lib/calendar/date.ex

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -83,28 +83,70 @@ defmodule Date do
8383
366
8484
iex> Enum.member?(range, ~D[2001-02-01])
8585
true
86-
iex> Enum.reduce(range, 0, fn _date, acc -> acc - 1 end)
87-
-366
86+
iex> Enum.take(range, 3)
87+
[~D[2001-01-01], ~D[2001-01-02], ~D[2001-01-03]]
8888
8989
"""
9090
@doc since: "1.5.0"
9191
@spec range(Calendar.date(), Calendar.date()) :: Date.Range.t()
9292
def range(%{calendar: calendar} = first, %{calendar: calendar} = last) do
9393
{first_days, _} = to_iso_days(first)
9494
{last_days, _} = to_iso_days(last)
95+
# TODO: Deprecate inferring a range with step of -1 on Elixir v1.16
96+
step = if first_days <= last_days, do: 1, else: -1
97+
range(first, first_days, last, last_days, calendar, step)
98+
end
99+
100+
def range(%{calendar: _, year: _, month: _, day: _}, %{calendar: _, year: _, month: _, day: _}) do
101+
raise ArgumentError, "both dates must have matching calendars"
102+
end
103+
104+
@doc """
105+
Returns a range of dates with step.
106+
107+
## Examples
108+
109+
iex> range = Date.range(~D[2001-01-01], ~D[2002-01-01], 2)
110+
iex> range
111+
#DateRange<~D[2001-01-01], ~D[2002-01-01], 2>
112+
iex> Enum.count(range)
113+
183
114+
iex> Enum.member?(range, ~D[2001-01-03])
115+
true
116+
iex> Enum.take(range, 3)
117+
[~D[2001-01-01], ~D[2001-01-03], ~D[2001-01-05]]
95118
119+
"""
120+
@doc since: "1.12.0"
121+
@spec range(Calendar.date(), Calendar.date(), step :: pos_integer | neg_integer) ::
122+
Date.Range.t()
123+
def range(%{calendar: calendar} = first, %{calendar: calendar} = last, step)
124+
when is_integer(step) and step != 0 do
125+
{first_days, _} = to_iso_days(first)
126+
{last_days, _} = to_iso_days(last)
127+
range(first, first_days, last, last_days, calendar, step)
128+
end
129+
130+
def range(
131+
%{calendar: _, year: _, month: _, day: _} = first,
132+
%{calendar: _, year: _, month: _, day: _} = last,
133+
step
134+
) do
135+
raise ArgumentError,
136+
"both dates must have matching calendar and the step must be an integer " <>
137+
"different than zero, got: #{inspect(first)}, #{inspect(last)}, #{step}"
138+
end
139+
140+
defp range(first, first_days, last, last_days, calendar, step) do
96141
%Date.Range{
97142
first: %Date{calendar: calendar, year: first.year, month: first.month, day: first.day},
98143
last: %Date{calendar: calendar, year: last.year, month: last.month, day: last.day},
99144
first_in_iso_days: first_days,
100-
last_in_iso_days: last_days
145+
last_in_iso_days: last_days,
146+
step: step
101147
}
102148
end
103149

104-
def range(%{calendar: _, year: _, month: _, day: _}, %{calendar: _, year: _, month: _, day: _}) do
105-
raise ArgumentError, "both dates must have matching calendars"
106-
end
107-
108150
@doc """
109151
Returns the current date in UTC.
110152
@@ -659,11 +701,12 @@ defmodule Date do
659701
end
660702
end
661703

662-
defp to_iso_days(%{calendar: Calendar.ISO, year: year, month: month, day: day}) do
704+
@doc false
705+
def to_iso_days(%{calendar: Calendar.ISO, year: year, month: month, day: day}) do
663706
{Calendar.ISO.date_to_iso_days(year, month, day), {0, 86_400_000_000}}
664707
end
665708

666-
defp to_iso_days(%{calendar: calendar, year: year, month: month, day: day}) do
709+
def to_iso_days(%{calendar: calendar, year: year, month: month, day: day}) do
667710
calendar.naive_datetime_to_iso_days(year, month, day, 0, 0, 0, {0, 0})
668711
end
669712

lib/elixir/lib/calendar/date_range.ex

Lines changed: 85 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ defmodule Date.Range do
22
@moduledoc """
33
Returns an inclusive range between dates.
44
5-
Ranges must be created with the `Date.range/2` function.
5+
Ranges must be created with the `Date.range/2` or `Date.range/3` function.
66
77
The following fields are public:
88
99
* `:first` - the initial date on the range
1010
* `:last` - the last date on the range
11+
* `:step` - (since v1.12.0) the step
1112
1213
The remaining fields are private and should not be accessed.
1314
"""
@@ -16,98 +17,98 @@ defmodule Date.Range do
1617
first: Date.t(),
1718
last: Date.t(),
1819
first_in_iso_days: iso_days(),
19-
last_in_iso_days: iso_days()
20+
last_in_iso_days: iso_days(),
21+
step: pos_integer | neg_integer
2022
}
2123

2224
@typep iso_days() :: Calendar.iso_days()
2325

24-
defstruct [:first, :last, :first_in_iso_days, :last_in_iso_days]
26+
defstruct [:first, :last, :first_in_iso_days, :last_in_iso_days, :step]
2527

2628
defimpl Enumerable do
2729
def member?(%{first: %{calendar: calendar}} = range, %Date{calendar: calendar} = date) do
2830
%{
29-
first: first,
30-
last: last,
31-
first_in_iso_days: first_in_iso_days,
32-
last_in_iso_days: last_in_iso_days
31+
first_in_iso_days: first_days,
32+
last_in_iso_days: last_days,
33+
step: step
3334
} = range
3435

35-
%{year: first_year, month: first_month, day: first_day} = first
36-
%{year: last_year, month: last_month, day: last_day} = last
37-
%{year: year, month: month, day: day} = date
38-
first = {first_year, first_month, first_day}
39-
last = {last_year, last_month, last_day}
40-
date = {year, month, day}
41-
42-
if first_in_iso_days <= last_in_iso_days do
43-
{:ok, date >= first and date <= last}
44-
else
45-
{:ok, date >= last and date <= first}
36+
{days, _} = Date.to_iso_days(date)
37+
38+
cond do
39+
empty?(range) ->
40+
{:ok, false}
41+
42+
first_days <= last_days ->
43+
{:ok, first_days <= days and days <= last_days and rem(days - first_days, step) == 0}
44+
45+
true ->
46+
{:ok, last_days <= days and days <= first_days and rem(days - first_days, step) == 0}
4647
end
4748
end
4849

4950
def member?(_, _) do
5051
{:ok, false}
5152
end
5253

53-
def count(%{first_in_iso_days: first, last_in_iso_days: last}) do
54-
{:ok, abs(first - last) + 1}
54+
def count(range) do
55+
{:ok, size(range)}
5556
end
5657

5758
def slice(range) do
5859
%{
5960
first_in_iso_days: first,
60-
last_in_iso_days: last,
61-
first: %{calendar: calendar}
61+
first: %{calendar: calendar},
62+
step: step
6263
} = range
6364

64-
if first <= last do
65-
{:ok, last - first + 1, &slice_asc(first + &1, &2, calendar)}
66-
else
67-
{:ok, first - last + 1, &slice_desc(first - &1, &2, calendar)}
68-
end
65+
{:ok, size(range), &slice(first + &1 * step, step, &2, calendar)}
6966
end
7067

71-
defp slice_asc(current, 1, calendar), do: [date_from_iso_days(current, calendar)]
72-
73-
defp slice_asc(current, remaining, calendar) do
74-
[date_from_iso_days(current, calendar) | slice_asc(current + 1, remaining - 1, calendar)]
68+
defp slice(current, _step, 1, calendar) do
69+
[date_from_iso_days(current, calendar)]
7570
end
7671

77-
defp slice_desc(current, 1, calendar), do: [date_from_iso_days(current, calendar)]
78-
79-
defp slice_desc(current, remaining, calendar) do
80-
[date_from_iso_days(current, calendar) | slice_desc(current - 1, remaining - 1, calendar)]
72+
defp slice(current, step, remaining, calendar) do
73+
[
74+
date_from_iso_days(current, calendar)
75+
| slice(current + step, step, remaining - 1, calendar)
76+
]
8177
end
8278

8379
def reduce(range, acc, fun) do
8480
%{
85-
first_in_iso_days: first_in_iso_days,
86-
last_in_iso_days: last_in_iso_days,
87-
first: %{calendar: calendar}
81+
first_in_iso_days: first_days,
82+
last_in_iso_days: last_days,
83+
first: %{calendar: calendar},
84+
step: step
8885
} = range
8986

90-
up? = first_in_iso_days <= last_in_iso_days
91-
reduce(first_in_iso_days, last_in_iso_days, acc, fun, calendar, up?)
87+
reduce(first_days, last_days, acc, fun, step, calendar)
9288
end
9389

94-
defp reduce(_x, _y, {:halt, acc}, _fun, _calendar, _up?) do
90+
defp reduce(_first_days, _last_days, {:halt, acc}, _fun, _step, _calendar) do
9591
{:halted, acc}
9692
end
9793

98-
defp reduce(x, y, {:suspend, acc}, fun, calendar, up?) do
99-
{:suspended, acc, &reduce(x, y, &1, fun, calendar, up?)}
94+
defp reduce(first_days, last_days, {:suspend, acc}, fun, step, calendar) do
95+
{:suspended, acc, &reduce(first_days, last_days, &1, fun, step, calendar)}
10096
end
10197

102-
defp reduce(x, y, {:cont, acc}, fun, calendar, up? = true) when x <= y do
103-
reduce(x + 1, y, fun.(date_from_iso_days(x, calendar), acc), fun, calendar, up?)
98+
defp reduce(first_days, last_days, {:cont, acc}, fun, step, calendar)
99+
when step > 0 and first_days <= last_days
100+
when step < 0 and first_days >= last_days do
101+
reduce(
102+
first_days + step,
103+
last_days,
104+
fun.(date_from_iso_days(first_days, calendar), acc),
105+
fun,
106+
step,
107+
calendar
108+
)
104109
end
105110

106-
defp reduce(x, y, {:cont, acc}, fun, calendar, up? = false) when x >= y do
107-
reduce(x - 1, y, fun.(date_from_iso_days(x, calendar), acc), fun, calendar, up?)
108-
end
109-
110-
defp reduce(_, _, {:cont, acc}, _fun, _calendar, _up) do
111+
defp reduce(_, _, {:cont, acc}, _fun, _step, _calendar) do
111112
{:done, acc}
112113
end
113114

@@ -122,11 +123,44 @@ defmodule Date.Range do
122123

123124
%Date{year: year, month: month, day: day, calendar: calendar}
124125
end
126+
127+
defp size(%Date.Range{first_in_iso_days: first_days, last_in_iso_days: last_days, step: step})
128+
when step > 0 and first_days > last_days,
129+
do: 0
130+
131+
defp size(%Date.Range{first_in_iso_days: first_days, last_in_iso_days: last_days, step: step})
132+
when step < 0 and first_days < last_days,
133+
do: 0
134+
135+
defp size(%Date.Range{first_in_iso_days: first_days, last_in_iso_days: last_days, step: step}),
136+
do: abs(div(last_days - first_days, step)) + 1
137+
138+
defp empty?(%Date.Range{
139+
first_in_iso_days: first_days,
140+
last_in_iso_days: last_days,
141+
step: step
142+
})
143+
when step > 0 and first_days > last_days,
144+
do: true
145+
146+
defp empty?(%Date.Range{
147+
first_in_iso_days: first_days,
148+
last_in_iso_days: last_days,
149+
step: step
150+
})
151+
when step < 0 and first_days < last_days,
152+
do: true
153+
154+
defp empty?(%Date.Range{}), do: false
125155
end
126156

127157
defimpl Inspect do
128-
def inspect(%Date.Range{first: first, last: last}, _) do
158+
def inspect(%Date.Range{first: first, last: last, step: 1}, _) do
129159
"#DateRange<" <> inspect(first) <> ", " <> inspect(last) <> ">"
130160
end
161+
162+
def inspect(%Date.Range{first: first, last: last, step: step}, _) do
163+
"#DateRange<" <> inspect(first) <> ", " <> inspect(last) <> ", #{step}>"
164+
end
131165
end
132166
end

0 commit comments

Comments
 (0)