diff --git a/lib/elixir/lib/calendar/date.ex b/lib/elixir/lib/calendar/date.ex index f51ae3e56d..4b2d5826a3 100644 --- a/lib/elixir/lib/calendar/date.ex +++ b/lib/elixir/lib/calendar/date.ex @@ -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. @@ -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: @@ -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) @@ -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. @@ -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) @@ -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}, @@ -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)) diff --git a/lib/elixir/test/elixir/calendar/date_range_test.exs b/lib/elixir/test/elixir/calendar/date_range_test.exs index 77f4c5b047..8715d307f5 100644 --- a/lib/elixir/test/elixir/calendar/date_range_test.exs +++ b/lib/elixir/test/elixir/calendar/date_range_test.exs @@ -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 @@ -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 @@ -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 @@ -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) @@ -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