Skip to content

Commit

Permalink
Support Duration in Date.range/3 (#14172)
Browse files Browse the repository at this point in the history
  • Loading branch information
tfiedlerdejanze authored Jan 13, 2025
1 parent aea54fc commit 8deaaf4
Show file tree
Hide file tree
Showing 2 changed files with 101 additions and 10 deletions.
63 changes: 58 additions & 5 deletions lib/elixir/lib/calendar/date.ex
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ defmodule Date do
calendar: Calendar.calendar()
}

@typedoc "A duration unit expressed as a tuple."
@typedoc since: "1.19.0"
@type duration_unit_pair ::
{:year, integer} | {:month, integer} | {:week, integer} | {:day, integer}

@doc """
Returns a range of dates.
Expand All @@ -84,6 +89,20 @@ defmodule Date do
iex> Date.range(~D[1999-01-01], ~D[2000-01-01])
Date.range(~D[1999-01-01], ~D[2000-01-01])
A range may also be built from a `Date` and a `Duration`
(also expressed as a keyword list of `t:duration_unit_pair/0`):
iex> Date.range(~D[1999-01-01], Duration.new!(year: 1))
Date.range(~D[1999-01-01], ~D[2000-01-01])
iex> Date.range(~D[1999-01-01], year: 1)
Date.range(~D[1999-01-01], ~D[2000-01-01])
> #### Durations {: .warning}
>
> Support for expressing `last` as a [`Duration`](`t:Duration.t/0`) or
> keyword list of `t:duration_unit_pair/0`s was introduced in
> v1.19.0.
A range of dates implements the `Enumerable` protocol, which means
functions in the `Enum` module can be used to work with
ranges:
Expand All @@ -100,7 +119,11 @@ defmodule Date do
"""
@doc since: "1.5.0"
@spec range(Calendar.date(), Calendar.date()) :: Date.Range.t()
@spec range(
first :: Calendar.date(),
last_or_duration :: Calendar.date() | Duration.t() | [duration_unit_pair]
) ::
Date.Range.t()
def range(%{calendar: calendar} = first, %{calendar: calendar} = last) do
{first_days, _} = to_iso_days(first)
{last_days, _} = to_iso_days(last)
Expand All @@ -123,6 +146,16 @@ defmodule Date do
raise ArgumentError, "both dates must have matching calendars"
end

def range(%{calendar: _} = first, %Duration{} = duration) do
last = shift(first, duration)
range(first, last)
end

def range(%{calendar: _} = first, duration) when is_list(duration) do
last = shift(first, duration)
range(first, last)
end

@doc """
Returns a range of dates with a step.
Expand All @@ -140,8 +173,11 @@ defmodule Date do
"""
@doc since: "1.12.0"
@spec range(Calendar.date(), Calendar.date(), step :: pos_integer | neg_integer) ::
Date.Range.t()
@spec range(
first :: Calendar.date(),
last_or_duration :: Calendar.date() | Duration.t() | [duration_unit_pair],
step :: pos_integer | neg_integer
) :: Date.Range.t()
def range(%{calendar: calendar} = first, %{calendar: calendar} = last, step)
when is_integer(step) and step != 0 do
{first_days, _} = to_iso_days(first)
Expand All @@ -159,6 +195,24 @@ defmodule Date do
"non-zero integer, got: #{inspect(first)}, #{inspect(last)}, #{step}"
end

def range(%{calendar: _} = first, %Duration{} = duration, step)
when is_integer(step) and step != 0 do
last = shift(first, duration)
range(first, last, step)
end

def range(%{calendar: _} = first, duration, step)
when is_list(duration) and is_integer(step) and step != 0 do
last = shift(first, duration)
range(first, last, step)
end

def range(%{calendar: _} = first, last, step) do
raise ArgumentError,
"expected a date or duration as second argument and the step must be a " <>
"non-zero integer, got: #{inspect(first)}, #{inspect(last)}, #{step}"
end

defp range(first, first_days, last, last_days, calendar, step) do
%Date.Range{
first: %Date{calendar: calendar, year: first.year, month: first.month, day: first.day},
Expand Down Expand Up @@ -795,8 +849,7 @@ defmodule Date do
"""
@doc since: "1.17.0"
@spec shift(Calendar.date(), Duration.t() | [unit_pair]) :: t
when unit_pair: {:year, integer} | {:month, integer} | {:week, integer} | {:day, integer}
@spec shift(Calendar.date(), Duration.t() | [duration_unit_pair]) :: t
def shift(%{calendar: calendar} = date, duration) do
%{year: year, month: month, day: day} = date
{year, month, day} = calendar.shift_date(year, month, day, __duration__!(duration))
Expand Down
48 changes: 43 additions & 5 deletions lib/elixir/test/elixir/calendar/date_range_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ defmodule Date.RangeTest do

@asc_range Date.range(~D[2000-01-01], ~D[2001-01-01])
@asc_range_2 Date.range(~D[2000-01-01], ~D[2001-01-01], 2)
@asc_range_duration Date.range(~D[2000-01-01], Duration.new!(year: 1))
@asc_range_duration_2 Date.range(~D[2000-01-01], Duration.new!(year: 1), 2)
@desc_range Date.range(~D[2001-01-01], ~D[2000-01-01], -1)
@desc_range_2 Date.range(~D[2001-01-01], ~D[2000-01-01], -2)
@desc_range_duration Date.range(~D[2001-01-01], Duration.new!(year: -1))
@desc_range_duration_2 Date.range(~D[2001-01-01], Duration.new!(year: -1), 2)
@empty_range Date.range(~D[2001-01-01], ~D[2000-01-01], 1)

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

assert Enum.member?(@asc_range_2, ~D[2000-01-03])
refute Enum.member?(@asc_range_2, ~D[2000-01-02])

assert Enum.member?(@asc_range_duration, ~D[2000-01-03])
refute Enum.member?(@asc_range_duration_2, ~D[2000-01-02])
end

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

assert Enum.member?(@desc_range_2, ~D[2000-12-30])
refute Enum.member?(@desc_range_2, ~D[2000-12-29])

assert Enum.member?(@desc_range_duration, ~D[2000-12-30])
refute Enum.member?(@desc_range_duration_2, ~D[2000-12-29])
end

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

test "works with durations" do
range = Date.range(~D[2000-01-01], Duration.new!(day: 1))
assert range.first == ~D[2000-01-01]
assert range.last == ~D[2000-01-02]
assert Enum.to_list(range) == [~D[2000-01-01], ~D[2000-01-02]]

range = Date.range(~D[2000-01-01], Duration.new!(day: 2), 2)
assert range.first == ~D[2000-01-01]
assert range.last == ~D[2000-01-03]
assert Enum.to_list(range) == [~D[2000-01-01], ~D[2000-01-03]]
end

test "accepts durations as keyword list" do
range = Date.range(~D[2000-01-01], day: 1)
assert range.first == ~D[2000-01-01]
assert range.last == ~D[2000-01-02]
assert Enum.to_list(range) == [~D[2000-01-01], ~D[2000-01-02]]

range = Date.range(~D[2000-01-01], [day: 2], 2)
assert range.first == ~D[2000-01-01]
assert range.last == ~D[2000-01-03]
assert Enum.to_list(range) == [~D[2000-01-01], ~D[2000-01-03]]
end

test "both dates must have matching calendars" do
first = ~D[2000-01-01]
last = Calendar.Holocene.date(12001, 1, 1)
Expand All @@ -129,18 +163,22 @@ defmodule Date.RangeTest do
end

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

assert_raise ArgumentError, message, fn ->
Date.range(~D[2000-01-01], ~D[2000-01-31], step)
Date.range(~D[2000-01-01], ~D[2000-01-31], 1.0)
end

step = 0
message = ~r"the step must be a non-zero integer"
assert_raise ArgumentError, message, fn ->
Date.range(~D[2000-01-01], [month: 1], 1.0)
end

assert_raise ArgumentError, message, fn ->
Date.range(~D[2000-01-01], ~D[2000-01-31], 0)
end

assert_raise ArgumentError, message, fn ->
Date.range(~D[2000-01-01], ~D[2000-01-31], step)
Date.range(~D[2000-01-01], [month: 1], 0)
end
end

Expand Down

0 comments on commit 8deaaf4

Please sign in to comment.