diff --git a/lib/elixir/lib/calendar.ex b/lib/elixir/lib/calendar.ex index 8bab6e2794b..09062f156bb 100644 --- a/lib/elixir/lib/calendar.ex +++ b/lib/elixir/lib/calendar.ex @@ -338,6 +338,34 @@ defmodule Calendar do @doc since: "1.15.0" @callback iso_days_to_end_of_day(iso_days) :: iso_days + @doc """ + Shifts date by given duration according to its calendar. + """ + @doc since: "1.17.0" + @callback shift_date(year, month, day, Duration.t()) :: {year, month, day} + + @doc """ + Shifts naive datetime by given duration according to its calendar. + """ + @doc since: "1.17.0" + @callback shift_naive_datetime( + year, + month, + day, + hour, + minute, + second, + microsecond, + Duration.t() + ) :: {year, month, day, hour, minute, second, microsecond} + + @doc """ + Shifts time by given duration according to its calendar. + """ + @doc since: "1.17.0" + @callback shift_time(hour, minute, second, microsecond, Duration.t()) :: + {hour, minute, second, microsecond} + # General Helpers @doc """ diff --git a/lib/elixir/lib/calendar/date.ex b/lib/elixir/lib/calendar/date.ex index 173365a7976..bcff3c379f0 100644 --- a/lib/elixir/lib/calendar/date.ex +++ b/lib/elixir/lib/calendar/date.ex @@ -40,7 +40,7 @@ defmodule Date do ## Using epochs - The `add/2` and `diff/2` functions can be used for computing dates + The `add/2`, `diff/2` and `shift/2` functions can be used for computing dates or retrieving the number of days between instants. For example, if there is an interest in computing the number of days from the Unix epoch (1970-01-01): @@ -51,6 +51,9 @@ defmodule Date do iex> Date.add(~D[1970-01-01], 14716) ~D[2010-04-17] + iex> Date.shift(~D[1970-01-01], year: 40, month: 3, week: 2, day: 2) + ~D[2010-04-17] + Those functions are optimized to deal with common epochs, such as the Unix Epoch above or the Gregorian Epoch (0000-01-01). """ @@ -687,6 +690,8 @@ defmodule Date do The days are counted as Gregorian days. The date is returned in the same calendar as it was given in. + To shift a date by a `Duration` and according to its underlying calendar, use `Date.shift/2`. + ## Examples iex> Date.add(~D[2000-01-03], -2) @@ -757,6 +762,54 @@ defmodule Date do end end + @doc """ + Shifts given `date` by `duration` according to its calendar. + + Allowed units are: `:year`, `:month`, `:week`, `:day`. + + When using the default ISO calendar, durations are collapsed and + applied in the order of months and then days: + - when shifting by 1 year and 2 months the date is actually shifted by 14 months + - when shifting by 2 weeks and 3 days the date is shifted by 17 days + + When shifting by month, days are rounded down to the nearest valid date. + + Raises an `ArgumentError` when called with time scale units. + + ## Examples + + iex> Date.shift(~D[2016-01-03], month: 2) + ~D[2016-03-03] + iex> Date.shift(~D[2016-01-30], month: -1) + ~D[2015-12-30] + iex> Date.shift(~D[2016-01-31], year: 4, day: 1) + ~D[2020-02-01] + iex> Date.shift(~D[2016-01-03], Duration.new!(month: 2)) + ~D[2016-03-03] + + # leap years + iex> Date.shift(~D[2024-02-29], year: 1) + ~D[2025-02-28] + iex> Date.shift(~D[2024-02-29], year: 4) + ~D[2028-02-29] + + # rounding down + iex> Date.shift(~D[2015-01-31], month: 1) + ~D[2015-02-28] + + """ + @doc since: "1.17.0" + @spec shift(Calendar.date(), Duration.t() | [Duration.unit_pair()]) :: t + def shift(%{calendar: calendar} = date, %Duration{} = duration) do + %{year: year, month: month, day: day} = date + {year, month, day} = calendar.shift_date(year, month, day, duration) + %Date{calendar: calendar, year: year, month: month, day: day} + end + + def shift(date, duration) do + shift(date, Duration.new!(duration)) + end + @doc false def to_iso_days(%{calendar: Calendar.ISO, year: year, month: month, day: day}) do {Calendar.ISO.date_to_iso_days(year, month, day), {0, 86_400_000_000}} diff --git a/lib/elixir/lib/calendar/datetime.ex b/lib/elixir/lib/calendar/datetime.ex index 119bf71a086..ce423dddc74 100644 --- a/lib/elixir/lib/calendar/datetime.ex +++ b/lib/elixir/lib/calendar/datetime.ex @@ -1610,6 +1610,8 @@ defmodule DateTime do iex> result.microsecond {21000, 3} + To shift a datetime by a `Duration` and according to its underlying calendar, use `DateTime.shift/3`. + """ @doc since: "1.8.0" @spec add( @@ -1674,6 +1676,159 @@ defmodule DateTime do end end + @doc """ + Shifts given `datetime` by `duration` according to its calendar. + + Allowed units are: `:year`, `:month`, `:week`, `:day`, `:hour`, `:minute`, `:second`, `:microsecond`. + + This operation is equivalent to shifting the datetime wall clock (in other words, + the values as we see them printed), then applying the time zone offset before + computing the new time zone. This ensures `shift/3` always returns a valid + datetime. + + On other other hand, time zones that observe "Daylight Saving Time" + or other changes, across summer/winter time will add/remove hours + from the resulting datetime: + + dt = DateTime.new!(~D[2019-03-31], ~T[01:00:00], "Europe/Copenhagen") + DateTime.shift(dt, hour: 1) + #=> #DateTime<2019-03-31 03:00:00+02:00 CEST Europe/Copenhagen> + + dt = DateTime.new!(~D[2018-11-04], ~T[00:00:00], "America/Los_Angeles") + DateTime.shift(dt, hour: 2) + #=> #DateTime<2018-11-04 01:00:00-08:00 PST America/Los_Angeles> + + In case you don't want these changes to happen automatically or you + want to surface timezone conflicts to the user, you can shift + the datetime as a naive datetime and then use `from_naive/2`: + + dt |> NaiveDateTime.shift(duration) |> DateTime.from_naive(dt.time_zone) + + When using the default ISO calendar, durations are collapsed and + applied in the order of months, then seconds and microseconds: + - when shifting by 1 year and 2 months the date is actually shifted by 14 months + - weeks, days and smaller units are collapsed into seconds and microseconds + + When shifting by month, days are rounded down to the nearest valid date. + + ## Examples + + iex> DateTime.shift(~U[2016-01-01 00:00:00Z], month: 2) + ~U[2016-03-01 00:00:00Z] + iex> DateTime.shift(~U[2016-01-01 00:00:00Z], year: 1, week: 4) + ~U[2017-01-29 00:00:00Z] + iex> DateTime.shift(~U[2016-01-01 00:00:00Z], minute: -25) + ~U[2015-12-31 23:35:00Z] + iex> DateTime.shift(~U[2016-01-01 00:00:00Z], minute: 5, microsecond: {500, 4}) + ~U[2016-01-01 00:05:00.0005Z] + + # leap years + iex> DateTime.shift(~U[2024-02-29 00:00:00Z], year: 1) + ~U[2025-02-28 00:00:00Z] + iex> DateTime.shift(~U[2024-02-29 00:00:00Z], year: 4) + ~U[2028-02-29 00:00:00Z] + + # rounding down + iex> DateTime.shift(~U[2015-01-31 00:00:00Z], month: 1) + ~U[2015-02-28 00:00:00Z] + + """ + @doc since: "1.17.0" + @spec shift( + Calendar.datetime(), + Duration.t() | [Duration.unit_pair()], + Calendar.time_zone_database() + ) :: t + def shift(datetime, duration, time_zone_database \\ Calendar.get_time_zone_database()) + + def shift( + %{calendar: calendar, time_zone: "Etc/UTC"} = datetime, + %Duration{} = duration, + _time_zone_database + ) do + %{ + year: year, + month: month, + day: day, + hour: hour, + minute: minute, + second: second, + microsecond: microsecond + } = datetime + + {year, month, day, hour, minute, second, microsecond} = + calendar.shift_naive_datetime( + year, + month, + day, + hour, + minute, + second, + microsecond, + duration + ) + + %DateTime{ + year: year, + month: month, + day: day, + hour: hour, + minute: minute, + second: second, + microsecond: microsecond, + time_zone: "Etc/UTC", + zone_abbr: "UTC", + std_offset: 0, + utc_offset: 0 + } + end + + def shift(%{calendar: calendar} = datetime, %Duration{} = duration, time_zone_database) do + %{ + year: year, + month: month, + day: day, + hour: hour, + minute: minute, + second: second, + microsecond: microsecond, + std_offset: std_offset, + utc_offset: utc_offset, + time_zone: time_zone + } = datetime + + {year, month, day, hour, minute, second, {_, precision} = microsecond} = + calendar.shift_naive_datetime( + year, + month, + day, + hour, + minute, + second, + microsecond, + duration + ) + + result = + calendar.naive_datetime_to_iso_days(year, month, day, hour, minute, second, microsecond) + |> apply_tz_offset(utc_offset + std_offset) + |> shift_zone_for_iso_days_utc(calendar, precision, time_zone, time_zone_database) + + case result do + {:ok, result_datetime} -> + result_datetime + + {:error, error} -> + raise ArgumentError, + "cannot shift #{inspect(datetime)} to #{inspect(duration)} (with time zone " <> + "database #{inspect(time_zone_database)}), reason: #{inspect(error)}" + end + end + + def shift(datetime, duration, time_zone_database) do + shift(datetime, Duration.new!(duration), time_zone_database) + end + @doc """ Returns the given datetime with the microsecond field truncated to the given precision (`:microsecond`, `:millisecond` or `:second`). diff --git a/lib/elixir/lib/calendar/duration.ex b/lib/elixir/lib/calendar/duration.ex new file mode 100644 index 00000000000..a49206ca408 --- /dev/null +++ b/lib/elixir/lib/calendar/duration.ex @@ -0,0 +1,197 @@ +defmodule Duration do + @moduledoc """ + Struct and functions for handling durations. + + A `Duration` struct represents a collection of time scale units, + allowing for manipulation and calculation of durations. + + Date and time scale units are represented as integers, allowing for both positive and negative values. + + Microseconds are represented using a tuple `{microsecond, precision}`. This ensures compatibility with + other calendar types implementing time, such as `Time`, `DateTime`, and `NaiveDateTime`. + """ + + @moduledoc since: "1.17.0" + + @derive {Inspect, optional: [:year, :month, :week, :day, :hour, :minute, :second, :microsecond]} + defstruct year: 0, + month: 0, + week: 0, + day: 0, + hour: 0, + minute: 0, + second: 0, + microsecond: {0, 0} + + @type t :: %Duration{ + year: integer, + month: integer, + week: integer, + day: integer, + hour: integer, + minute: integer, + second: integer, + microsecond: {integer, 0..6} + } + + @type unit_pair :: + {:year, integer} + | {:month, integer} + | {:week, integer} + | {:day, integer} + | {:hour, integer} + | {:minute, integer} + | {:second, integer} + | {:microsecond, {integer, 0..6}} + + @doc """ + Creates a new `Duration` struct from given `unit_pairs`. + + Raises a `KeyError` when called with invalid unit keys. + + Raises an `ArgumentError` when called with invalid unit values. + + ## Examples + + iex> Duration.new!(year: 1, week: 3, hour: 4, second: 1) + %Duration{year: 1, week: 3, hour: 4, second: 1} + iex> Duration.new!(second: 1, microsecond: {1000, 6}) + %Duration{second: 1, microsecond: {1000, 6}} + iex> Duration.new!(month: 2) + %Duration{month: 2} + + """ + @spec new!([unit_pair]) :: t + def new!(unit_pairs) do + Enum.each(unit_pairs, &validate_duration_unit!/1) + struct!(Duration, unit_pairs) + end + + defp validate_duration_unit!({:microsecond, {ms, precision}}) + when is_integer(ms) and precision in 0..6 do + :ok + end + + defp validate_duration_unit!({:microsecond, microsecond}) do + raise ArgumentError, + "expected a tuple {ms, precision} for microsecond where precision is an integer from 0 to 6, got #{inspect(microsecond)}" + end + + defp validate_duration_unit!({_unit, value}) when is_integer(value) do + :ok + end + + defp validate_duration_unit!({unit, value}) do + raise ArgumentError, "expected an integer for #{inspect(unit)}, got #{inspect(value)}" + end + + @doc """ + Adds units of given durations `d1` and `d2`. + + Respects the the highest microsecond precision of the two. + + ## Examples + + iex> Duration.add(%Duration{week: 2, day: 1}, %Duration{day: 2}) + %Duration{week: 2, day: 3} + iex> Duration.add(%Duration{microsecond: {400, 3}}, %Duration{microsecond: {600, 6}}) + %Duration{microsecond: {1000, 6}} + + """ + @spec add(t, t) :: t + def add(%Duration{} = d1, %Duration{} = d2) do + {m1, p1} = d1.microsecond + {m2, p2} = d2.microsecond + + %Duration{ + year: d1.year + d2.year, + month: d1.month + d2.month, + week: d1.week + d2.week, + day: d1.day + d2.day, + hour: d1.hour + d2.hour, + minute: d1.minute + d2.minute, + second: d1.second + d2.second, + microsecond: {m1 + m2, max(p1, p2)} + } + end + + @doc """ + Subtracts units of given durations `d1` and `d2`. + + Respects the the highest microsecond precision of the two. + + ## Examples + + iex> Duration.subtract(%Duration{week: 2, day: 1}, %Duration{day: 2}) + %Duration{week: 2, day: -1} + iex> Duration.subtract(%Duration{microsecond: {400, 6}}, %Duration{microsecond: {600, 3}}) + %Duration{microsecond: {-200, 6}} + + """ + @spec subtract(t, t) :: t + def subtract(%Duration{} = d1, %Duration{} = d2) do + {m1, p1} = d1.microsecond + {m2, p2} = d2.microsecond + + %Duration{ + year: d1.year - d2.year, + month: d1.month - d2.month, + week: d1.week - d2.week, + day: d1.day - d2.day, + hour: d1.hour - d2.hour, + minute: d1.minute - d2.minute, + second: d1.second - d2.second, + microsecond: {m1 - m2, max(p1, p2)} + } + end + + @doc """ + Multiplies `duration` units by given `integer`. + + ## Examples + + iex> Duration.multiply(%Duration{day: 1, minute: 15, second: -10}, 3) + %Duration{day: 3, minute: 45, second: -30} + iex> Duration.multiply(%Duration{microsecond: {200, 4}}, 3) + %Duration{microsecond: {600, 4}} + + """ + @spec multiply(t, integer) :: t + def multiply(%Duration{microsecond: {ms, p}} = duration, integer) when is_integer(integer) do + %Duration{ + year: duration.year * integer, + month: duration.month * integer, + week: duration.week * integer, + day: duration.day * integer, + hour: duration.hour * integer, + minute: duration.minute * integer, + second: duration.second * integer, + microsecond: {ms * integer, p} + } + end + + @doc """ + Negates `duration` units. + + ## Examples + + iex> Duration.negate(%Duration{day: 1, minute: 15, second: -10}) + %Duration{day: -1, minute: -15, second: 10} + iex> Duration.negate(%Duration{microsecond: {500000, 4}}) + %Duration{microsecond: {-500000, 4}} + + """ + @spec negate(t) :: t + def negate(%Duration{microsecond: {ms, p}} = duration) do + %Duration{ + year: -duration.year, + month: -duration.month, + week: -duration.week, + day: -duration.day, + hour: -duration.hour, + minute: -duration.minute, + second: -duration.second, + microsecond: {-ms, p} + } + end +end diff --git a/lib/elixir/lib/calendar/iso.ex b/lib/elixir/lib/calendar/iso.ex index 99c977c127c..564a203120e 100644 --- a/lib/elixir/lib/calendar/iso.ex +++ b/lib/elixir/lib/calendar/iso.ex @@ -1455,6 +1455,224 @@ defmodule Calendar.ISO do {days, {@parts_per_day - 1, @parts_per_day}} end + @doc """ + Shifts Date by Duration according to its calendar. + + ## Examples + + iex> Calendar.ISO.shift_date(2016, 1, 3, Duration.new!(month: 2)) + {2016, 3, 3} + iex> Calendar.ISO.shift_date(2016, 2, 29, Duration.new!(month: 1)) + {2016, 3, 29} + iex> Calendar.ISO.shift_date(2016, 1, 31, Duration.new!(month: 1)) + {2016, 2, 29} + iex> Calendar.ISO.shift_date(2016, 1, 31, Duration.new!(year: 4, day: 1)) + {2020, 2, 1} + """ + @impl true + @spec shift_date(year, month, day, Duration.t()) :: {year, month, day} + def shift_date(year, month, day, duration) do + shift_options = shift_date_options(duration) + + Enum.reduce(shift_options, {year, month, day}, fn + {_, 0}, date -> + date + + {:month, value}, date -> + shift_months(date, value) + + {:day, value}, date -> + shift_days(date, value) + end) + end + + @doc """ + Shifts NaiveDateTime by Duration according to its calendar. + + ## Examples + + iex> Calendar.ISO.shift_naive_datetime(2016, 1, 3, 0, 0, 0, {0, 0}, Duration.new!(hour: 1)) + {2016, 1, 3, 1, 0, 0, {0, 0}} + iex> Calendar.ISO.shift_naive_datetime(2016, 1, 3, 0, 0, 0, {0, 0}, Duration.new!(hour: 30)) + {2016, 1, 4, 6, 0, 0, {0, 0}} + iex> Calendar.ISO.shift_naive_datetime(2016, 1, 3, 0, 0, 0, {0, 0}, Duration.new!(microsecond: {100, 6})) + {2016, 1, 3, 0, 0, 0, {100, 6}} + """ + @impl true + @spec shift_naive_datetime( + year, + month, + day, + hour, + minute, + second, + microsecond, + Duration.t() + ) :: {year, month, day, hour, minute, second, microsecond} + def shift_naive_datetime(year, month, day, hour, minute, second, microsecond, duration) do + shift_options = shift_datetime_options(duration) + + Enum.reduce(shift_options, {year, month, day, hour, minute, second, microsecond}, fn + {_, 0}, naive_datetime -> + naive_datetime + + {:month, value}, {year, month, day, hour, minute, second, microsecond} -> + {new_year, new_month, new_day} = shift_months({year, month, day}, value) + {new_year, new_month, new_day, hour, minute, second, microsecond} + + {time_unit, value}, naive_datetime -> + shift_time_unit(naive_datetime, value, time_unit) + end) + end + + @doc """ + Shifts Time by Duration units according to its calendar. + + ## Examples + + iex> Calendar.ISO.shift_time(13, 0, 0, {0, 0}, Duration.new!(hour: 2)) + {15, 0, 0, {0, 0}} + iex> Calendar.ISO.shift_time(13, 0, 0, {0, 0}, Duration.new!(microsecond: {100, 6})) + {13, 0, 0, {100, 6}} + """ + @impl true + @spec shift_time(hour, minute, second, microsecond, Duration.t()) :: + {hour, minute, second, microsecond} + def shift_time(hour, minute, second, microsecond, duration) do + shift_options = shift_time_options(duration) + + Enum.reduce(shift_options, {hour, minute, second, microsecond}, fn + {_, 0}, time -> + time + + {time_unit, value}, time -> + shift_time_unit(time, value, time_unit) + end) + end + + defp shift_days({year, month, day}, days) do + {year, month, day} = + date_to_iso_days(year, month, day) + |> Kernel.+(days) + |> date_from_iso_days() + + {year, month, day} + end + + defp shift_months({year, month, day}, months) do + months_in_year = 12 + total_months = year * months_in_year + month + months - 1 + + new_year = Integer.floor_div(total_months, months_in_year) + + new_month = + case rem(total_months, months_in_year) + 1 do + new_month when new_month < 1 -> new_month + months_in_year + new_month -> new_month + end + + new_day = min(day, days_in_month(new_year, new_month)) + + {new_year, new_month, new_day} + end + + defp shift_time_unit({year, month, day, hour, minute, second, microsecond}, value, unit) + when unit in [:second, :microsecond] do + {value, precision} = shift_time_unit_values(value, microsecond) + + ppd = System.convert_time_unit(86400, :second, unit) + + {year, month, day, hour, minute, second, {ms_value, _}} = + naive_datetime_to_iso_days(year, month, day, hour, minute, second, microsecond) + |> add_day_fraction_to_iso_days(value, ppd) + |> naive_datetime_from_iso_days() + + {year, month, day, hour, minute, second, {ms_value, precision}} + end + + defp shift_time_unit({hour, minute, second, microsecond}, value, unit) + when unit in [:second, :microsecond] do + {value, precision} = shift_time_unit_values(value, microsecond) + + time = {0, time_to_day_fraction(hour, minute, second, microsecond)} + amount_to_add = System.convert_time_unit(value, unit, :microsecond) + total = iso_days_to_unit(time, :microsecond) + amount_to_add + parts = Integer.mod(total, @parts_per_day) + + {hour, minute, second, {microsecond, _}} = time_from_day_fraction({parts, @parts_per_day}) + + {hour, minute, second, {microsecond, precision}} + end + + defp shift_time_unit_values({0, _}, {_, original_precision}) do + {0, original_precision} + end + + defp shift_time_unit_values({ms_value, ms_precision}, {_, _}) do + {ms_value, ms_precision} + end + + defp shift_time_unit_values(value, {_, original_precision}) do + {value, original_precision} + end + + defp shift_date_options(%Duration{ + year: year, + month: month, + week: week, + day: day, + hour: 0, + minute: 0, + second: 0, + microsecond: {0, 0} + }) do + [ + month: year * 12 + month, + day: week * 7 + day + ] + end + + defp shift_date_options(_duration) do + raise ArgumentError, "cannot shift date by time units" + end + + defp shift_datetime_options(%Duration{ + year: year, + month: month, + week: week, + day: day, + hour: hour, + minute: minute, + second: second, + microsecond: microsecond + }) do + [ + month: year * 12 + month, + second: week * 7 * 86400 + day * 86400 + hour * 3600 + minute * 60 + second, + microsecond: microsecond + ] + end + + defp shift_time_options(%Duration{ + year: 0, + month: 0, + week: 0, + day: 0, + hour: hour, + minute: minute, + second: second, + microsecond: microsecond + }) do + [ + second: hour * 3600 + minute * 60 + second, + microsecond: microsecond + ] + end + + defp shift_time_options(_duration) do + raise ArgumentError, "cannot shift time by date units" + end + ## Helpers @doc false diff --git a/lib/elixir/lib/calendar/naive_datetime.ex b/lib/elixir/lib/calendar/naive_datetime.ex index fe6674679a3..44a250b1542 100644 --- a/lib/elixir/lib/calendar/naive_datetime.ex +++ b/lib/elixir/lib/calendar/naive_datetime.ex @@ -448,6 +448,8 @@ defmodule NaiveDateTime do iex> NaiveDateTime.add(dt, 21, :second) ~N[2000-02-29 23:00:28] + To shift a naive datetime by a `Duration` and according to its underlying calendar, use `NaiveDateTime.shift/2`. + """ @doc since: "1.4.0" @spec add(Calendar.naive_datetime(), integer, :day | :hour | :minute | System.time_unit()) :: t @@ -571,6 +573,83 @@ defmodule NaiveDateTime do units1 - units2 end + @doc """ + Shifts given `naive_datetime` by `duration` according to its calendar. + + Allowed units are: `:year`, `:month`, `:week`, `:day`, `:hour`, `:minute`, `:second`, `:microsecond`. + + When using the default ISO calendar, durations are collapsed and + applied in the order of months, then seconds and microseconds: + - when shifting by 1 year and 2 months the date is actually shifted by 14 months + - weeks, days and smaller units are collapsed into seconds and microseconds + + When shifting by month, days are rounded down to the nearest valid date. + + ## Examples + + iex> NaiveDateTime.shift(~N[2016-01-31 00:00:00], month: 1) + ~N[2016-02-29 00:00:00] + iex> NaiveDateTime.shift(~N[2016-01-31 00:00:00], year: 4, day: 1) + ~N[2020-02-01 00:00:00] + iex> NaiveDateTime.shift(~N[2016-01-31 00:00:00], year: -2, day: 1) + ~N[2014-02-01 00:00:00] + iex> NaiveDateTime.shift(~N[2016-01-31 00:00:00], second: 45) + ~N[2016-01-31 00:00:45] + iex> NaiveDateTime.shift(~N[2016-01-31 00:00:00], microsecond: {100, 6}) + ~N[2016-01-31 00:00:00.000100] + + # leap years + iex> NaiveDateTime.shift(~N[2024-02-29 00:00:00], year: 1) + ~N[2025-02-28 00:00:00] + iex> NaiveDateTime.shift(~N[2024-02-29 00:00:00], year: 4) + ~N[2028-02-29 00:00:00] + + # rounding down + iex> NaiveDateTime.shift(~N[2015-01-31 00:00:00], month: 1) + ~N[2015-02-28 00:00:00] + + """ + @doc since: "1.17.0" + @spec shift(Calendar.naive_datetime(), Duration.t() | [Duration.unit_pair()]) :: t + def shift(%{calendar: calendar} = naive_datetime, %Duration{} = duration) do + %{ + year: year, + month: month, + day: day, + hour: hour, + minute: minute, + second: second, + microsecond: microsecond + } = naive_datetime + + {year, month, day, hour, minute, second, microsecond} = + calendar.shift_naive_datetime( + year, + month, + day, + hour, + minute, + second, + microsecond, + duration + ) + + %NaiveDateTime{ + calendar: calendar, + year: year, + month: month, + day: day, + hour: hour, + minute: minute, + second: second, + microsecond: microsecond + } + end + + def shift(naive_datetime, duration) do + shift(naive_datetime, Duration.new!(duration)) + end + @doc """ Returns the given naive datetime with the microsecond field truncated to the given precision (`:microsecond`, `:millisecond` or `:second`). diff --git a/lib/elixir/lib/calendar/time.ex b/lib/elixir/lib/calendar/time.ex index 43ef7a6e2d5..981b8a96f01 100644 --- a/lib/elixir/lib/calendar/time.ex +++ b/lib/elixir/lib/calendar/time.ex @@ -500,6 +500,8 @@ defmodule Time do iex> result.microsecond {21000, 3} + To shift a time by a `Duration` and according to its underlying calendar, use `Time.shift/2`. + """ @doc since: "1.6.0" @spec add(Calendar.time(), integer, :hour | :minute | System.time_unit()) :: t @@ -558,6 +560,51 @@ defmodule Time do Calendar.ISO.iso_days_to_unit(iso_days, :microsecond) end + @doc """ + Shifts given `time` by `duration` according to its calendar. + + Available duration units are: `:hour`, `:minute`, `:second`, `:microsecond`. + + When using the default ISO calendar, durations are collapsed to seconds and + microseconds before they are applied. + + Raises an `ArgumentError` when called with date scale units. + + ## Examples + + iex> Time.shift(~T[01:00:15], hour: 12) + ~T[13:00:15] + iex> Time.shift(~T[01:35:00], hour: 6, minute: -15) + ~T[07:20:00] + iex> Time.shift(~T[01:15:00], second: 125) + ~T[01:17:05] + iex> Time.shift(~T[01:00:15], microsecond: {100, 6}) + ~T[01:00:15.000100] + iex> Time.shift(~T[01:15:00], Duration.new!(second: 65)) + ~T[01:16:05] + + """ + @doc since: "1.17.0" + @spec shift(Calendar.time(), Duration.t() | [Duration.unit_pair()]) :: t + def shift(%{calendar: calendar} = time, %Duration{} = duration) do + %{hour: hour, minute: minute, second: second, microsecond: microsecond} = time + + {hour, minute, second, microsecond} = + calendar.shift_time(hour, minute, second, microsecond, duration) + + %Time{ + calendar: calendar, + hour: hour, + minute: minute, + second: second, + microsecond: microsecond + } + end + + def shift(time, duration) do + shift(time, Duration.new!(duration)) + end + @doc """ Compares two time structs. diff --git a/lib/elixir/scripts/elixir_docs.exs b/lib/elixir/scripts/elixir_docs.exs index 011547e81a9..d249184aab9 100644 --- a/lib/elixir/scripts/elixir_docs.exs +++ b/lib/elixir/scripts/elixir_docs.exs @@ -93,6 +93,7 @@ canonical = System.fetch_env!("CANONICAL") Bitwise, Date, DateTime, + Duration, Exception, Float, Function, diff --git a/lib/elixir/test/elixir/calendar/date_test.exs b/lib/elixir/test/elixir/calendar/date_test.exs index abbd8b75e63..68d1ca8d05e 100644 --- a/lib/elixir/test/elixir/calendar/date_test.exs +++ b/lib/elixir/test/elixir/calendar/date_test.exs @@ -179,4 +179,33 @@ defmodule DateTest do assert Date.diff(date1, date2) == -13 assert Date.diff(date2, date1) == 13 end + + test "shift/2" do + assert Date.shift(~D[2012-02-29], day: -1) == ~D[2012-02-28] + assert Date.shift(~D[2012-02-29], month: -1) == ~D[2012-01-29] + assert Date.shift(~D[2012-02-29], week: -9) == ~D[2011-12-28] + assert Date.shift(~D[2012-02-29], month: 1) == ~D[2012-03-29] + assert Date.shift(~D[2012-02-29], year: -1) == ~D[2011-02-28] + assert Date.shift(~D[2012-02-29], year: 4) == ~D[2016-02-29] + assert Date.shift(~D[0000-01-01], day: -1) == ~D[-0001-12-31] + assert Date.shift(~D[0000-01-01], month: -1) == ~D[-0001-12-01] + assert Date.shift(~D[0000-01-01], year: -1) == ~D[-0001-01-01] + assert Date.shift(~D[0000-01-01], year: -1) == ~D[-0001-01-01] + assert Date.shift(~D[2000-01-01], month: 12) == ~D[2001-01-01] + assert Date.shift(~D[0000-01-01], day: 2, year: 1, month: 37) == ~D[0004-02-03] + + assert_raise ArgumentError, ~s/cannot shift date by time units/, fn -> + Date.shift(~D[2012-02-29], second: 86400) + end + + assert_raise KeyError, ~s/key :months not found/, fn -> + Date.shift(~D[2012-01-01], months: 12) + end + + # Implements calendar callback + assert_raise RuntimeError, "shift_date/4 not implemented", fn -> + date = Calendar.Holocene.date(10000, 01, 01) + Date.shift(date, month: 1) + end + end end diff --git a/lib/elixir/test/elixir/calendar/datetime_test.exs b/lib/elixir/test/elixir/calendar/datetime_test.exs index e988874e928..eacddc15b32 100644 --- a/lib/elixir/test/elixir/calendar/datetime_test.exs +++ b/lib/elixir/test/elixir/calendar/datetime_test.exs @@ -1071,4 +1071,88 @@ defmodule DateTimeTest do assert catch_error(DateTime.to_naive(~N[2000-02-29 12:23:34])) end end + + test "shift/2" do + assert DateTime.shift(~U[2000-01-01 00:00:00Z], year: 1) == ~U[2001-01-01 00:00:00Z] + assert DateTime.shift(~U[2000-01-01 00:00:00Z], month: 1) == ~U[2000-02-01 00:00:00Z] + assert DateTime.shift(~U[2000-01-01 00:00:00Z], month: 1, day: 28) == ~U[2000-02-29 00:00:00Z] + assert DateTime.shift(~U[2000-01-01 00:00:00Z], month: 1, day: 30) == ~U[2000-03-02 00:00:00Z] + assert DateTime.shift(~U[2000-01-01 00:00:00Z], month: 2, day: 29) == ~U[2000-03-30 00:00:00Z] + + assert DateTime.shift(~U[2000-01-01 00:00:00Z], microsecond: {4000, 4}) == + ~U[2000-01-01 00:00:00.0040Z] + + assert DateTime.shift(~U[2000-02-29 00:00:00Z], year: -1) == ~U[1999-02-28 00:00:00Z] + assert DateTime.shift(~U[2000-02-29 00:00:00Z], month: -1) == ~U[2000-01-29 00:00:00Z] + + assert DateTime.shift(~U[2000-02-29 00:00:00Z], month: -1, day: -28) == + ~U[2000-01-01 00:00:00Z] + + assert DateTime.shift(~U[2000-02-29 00:00:00Z], month: -1, day: -30) == + ~U[1999-12-30 00:00:00Z] + + assert DateTime.shift(~U[2000-02-29 00:00:00Z], month: -1, day: -29) == + ~U[1999-12-31 00:00:00Z] + + datetime = + DateTime.new!(~D[2018-11-04], ~T[03:00:00], "America/Los_Angeles", FakeTimeZoneDatabase) + + assert DateTime.shift(datetime, [month: -1], FakeTimeZoneDatabase) == + %DateTime{ + calendar: Calendar.ISO, + year: 2018, + month: 10, + day: 4, + hour: 4, + minute: 0, + second: 0, + microsecond: {0, 0}, + time_zone: "America/Los_Angeles", + std_offset: 3600, + utc_offset: -28800, + zone_abbr: "PDT" + } + + datetime = + DateTime.new!(~D[2018-11-04], ~T[00:00:00], "America/Los_Angeles", FakeTimeZoneDatabase) + + assert DateTime.shift(datetime, [hour: 2], FakeTimeZoneDatabase) == + %DateTime{ + calendar: Calendar.ISO, + year: 2018, + month: 11, + day: 4, + hour: 1, + minute: 0, + second: 0, + microsecond: {0, 0}, + time_zone: "America/Los_Angeles", + std_offset: 0, + utc_offset: -28800, + zone_abbr: "PST" + } + + datetime = + DateTime.new!(~D[2019-03-31], ~T[01:00:00], "Europe/Copenhagen", FakeTimeZoneDatabase) + + assert DateTime.shift(datetime, [hour: 1], FakeTimeZoneDatabase) == + %DateTime{ + calendar: Calendar.ISO, + year: 2019, + month: 03, + day: 31, + hour: 3, + minute: 0, + second: 0, + microsecond: {0, 0}, + time_zone: "Europe/Copenhagen", + std_offset: 3600, + utc_offset: 3600, + zone_abbr: "CEST" + } + + assert_raise KeyError, ~s/key :months not found/, fn -> + DateTime.shift(~U[2012-01-01 00:00:00Z], months: 12) + end + end end diff --git a/lib/elixir/test/elixir/calendar/duration_test.exs b/lib/elixir/test/elixir/calendar/duration_test.exs new file mode 100644 index 00000000000..1ecbbff9dc2 --- /dev/null +++ b/lib/elixir/test/elixir/calendar/duration_test.exs @@ -0,0 +1,220 @@ +Code.require_file("../test_helper.exs", __DIR__) + +defmodule DurationTest do + use ExUnit.Case, async: true + doctest Duration + + test "new!/1" do + assert Duration.new!(year: 2, month: 1, week: 3) == %Duration{year: 2, month: 1, week: 3} + assert Duration.new!(microsecond: {20000, 2}) == %Duration{microsecond: {20000, 2}} + + assert_raise KeyError, ~s/key :months not found/, fn -> + Duration.new!(months: 1) + end + + assert_raise ArgumentError, + ~s/expected a tuple {ms, precision} for microsecond where precision is an integer from 0 to 6, got {1, 2, 3}/, + fn -> + Duration.new!(microsecond: {1, 2, 3}) + end + + assert_raise ArgumentError, + ~s/expected a tuple {ms, precision} for microsecond where precision is an integer from 0 to 6, got {100, 7}/, + fn -> + Duration.new!(microsecond: {100, 7}) + end + end + + test "add/2" do + d1 = %Duration{ + year: 1, + month: 2, + week: 3, + day: 4, + hour: 5, + minute: 6, + second: 7, + microsecond: {8, 6} + } + + d2 = %Duration{ + year: 8, + month: 7, + week: 6, + day: 5, + hour: 4, + minute: 3, + second: 2, + microsecond: {1, 6} + } + + assert Duration.add(d1, d2) == %Duration{ + year: 9, + month: 9, + week: 9, + day: 9, + hour: 9, + minute: 9, + second: 9, + microsecond: {9, 6} + } + + assert Duration.add(d1, d2) == Duration.add(d2, d1) + + d1 = %Duration{month: 2, week: 3, day: 4} + d2 = %Duration{year: 8, day: 2, second: 2} + + assert Duration.add(d1, d2) == %Duration{ + year: 8, + month: 2, + week: 3, + day: 6, + hour: 0, + minute: 0, + second: 2, + microsecond: {0, 0} + } + + d1 = %Duration{microsecond: {1000, 4}} + d2 = %Duration{microsecond: {5, 6}} + assert Duration.add(d1, d2) == %Duration{microsecond: {1005, 6}} + end + + test "subtract/2" do + d1 = %Duration{ + year: 1, + month: 2, + week: 3, + day: 4, + hour: 5, + minute: 6, + second: 7, + microsecond: {8, 6} + } + + d2 = %Duration{ + year: 8, + month: 7, + week: 6, + day: 5, + hour: 4, + minute: 3, + second: 2, + microsecond: {1, 6} + } + + assert Duration.subtract(d1, d2) == %Duration{ + year: -7, + month: -5, + week: -3, + day: -1, + hour: 1, + minute: 3, + second: 5, + microsecond: {7, 6} + } + + assert Duration.subtract(d2, d1) == %Duration{ + year: 7, + month: 5, + week: 3, + day: 1, + hour: -1, + minute: -3, + second: -5, + microsecond: {-7, 6} + } + + assert Duration.subtract(d1, d2) != Duration.subtract(d2, d1) + + d1 = %Duration{year: 10, month: 2, week: 3, day: 4} + d2 = %Duration{year: 8, day: 2, second: 2} + + assert Duration.subtract(d1, d2) == %Duration{ + year: 2, + month: 2, + week: 3, + day: 2, + hour: 0, + minute: 0, + second: -2, + microsecond: {0, 0} + } + + d1 = %Duration{microsecond: {1000, 4}} + d2 = %Duration{microsecond: {5, 6}} + assert Duration.subtract(d1, d2) == %Duration{microsecond: {995, 6}} + end + + test "multiply/2" do + duration = %Duration{ + year: 1, + month: 2, + week: 3, + day: 4, + hour: 5, + minute: 6, + second: 7, + microsecond: {8, 6} + } + + assert Duration.multiply(duration, 3) == %Duration{ + year: 3, + month: 6, + week: 9, + day: 12, + hour: 15, + minute: 18, + second: 21, + microsecond: {24, 6} + } + + assert Duration.multiply(%Duration{year: 2, day: 4, minute: 5}, 4) == + %Duration{ + year: 8, + month: 0, + week: 0, + day: 16, + hour: 0, + minute: 20, + second: 0, + microsecond: {0, 0} + } + end + + test "negate/1" do + duration = %Duration{ + year: 1, + month: 2, + week: 3, + day: 4, + hour: 5, + minute: 6, + second: 7, + microsecond: {8, 6} + } + + assert Duration.negate(duration) == %Duration{ + year: -1, + month: -2, + week: -3, + day: -4, + hour: -5, + minute: -6, + second: -7, + microsecond: {-8, 6} + } + + assert Duration.negate(%Duration{year: 2, day: 4, minute: 5}) == + %Duration{ + year: -2, + month: 0, + week: 0, + day: -4, + hour: 0, + minute: -5, + second: 0, + microsecond: {0, 0} + } + end +end diff --git a/lib/elixir/test/elixir/calendar/fakes.exs b/lib/elixir/test/elixir/calendar/fakes.exs index 3da151e0ac4..490a25f5949 100644 --- a/lib/elixir/test/elixir/calendar/fakes.exs +++ b/lib/elixir/test/elixir/calendar/fakes.exs @@ -53,6 +53,22 @@ defmodule FakeTimeZoneDatabase do until_wall: ~N[2019-10-27 03:00:00] } + @time_zone_period_usla_summer_2018 %{ + std_offset: 3600, + utc_offset: -28800, + zone_abbr: "PDT", + from_wall: ~N[2018-03-11 02:00:00], + until_wall: ~N[2018-11-04 02:00:00] + } + + @time_zone_period_usla_winter_2018_2019 %{ + std_offset: 0, + utc_offset: -28800, + zone_abbr: "PST", + from_wall: ~N[2018-11-04 02:00:00], + until_wall: ~N[2019-03-10 03:00:00] + } + @spec time_zone_period_from_utc_iso_days(Calendar.iso_days(), Calendar.time_zone()) :: {:ok, TimeZoneDatabase.time_zone_period()} | {:error, :time_zone_not_found} @impl true @@ -103,13 +119,23 @@ defmodule FakeTimeZoneDatabase do end defp time_zone_periods_from_utc("America/Los_Angeles", erl_datetime) - when erl_datetime >= {{2018, 3, 11}, {10, 0, 0}} and - erl_datetime < {{2018, 11, 4}, {9, 0, 0}} do + when erl_datetime >= {{2018, 3, 11}, {2, 0, 0}} and + erl_datetime < {{2018, 11, 4}, {2, 0, 0}} do + {:ok, @time_zone_period_usla_summer_2018} + end + + defp time_zone_periods_from_utc("America/Los_Angeles", erl_datetime) + when erl_datetime >= {{2018, 11, 4}, {2, 0, 0}} and + erl_datetime < {{2019, 3, 10}, {3, 0, 0}} do + {:ok, @time_zone_period_usla_winter_2018_2019} + end + + defp time_zone_periods_from_utc("Etc/UTC", _erl_datetime) do {:ok, %{ - std_offset: 3600, - utc_offset: -28800, - zone_abbr: "PDT" + std_offset: 0, + utc_offset: 0, + zone_abbr: "UTC" }} end @@ -149,6 +175,18 @@ defmodule FakeTimeZoneDatabase do {:ok, @time_zone_period_cph_summer_2019} end + defp time_zone_periods_from_wall("America/Los_Angeles", erl_datetime) + when erl_datetime >= {{2018, 3, 11}, {2, 0, 0}} and + erl_datetime < {{2018, 11, 4}, {2, 0, 0}} do + {:ok, @time_zone_period_usla_summer_2018} + end + + defp time_zone_periods_from_wall("America/Los_Angeles", erl_datetime) + when erl_datetime >= {{2018, 11, 4}, {3, 0, 0}} and + erl_datetime < {{2019, 3, 10}, {3, 0, 0}} do + {:ok, @time_zone_period_usla_winter_2018_2019} + end + defp time_zone_periods_from_wall("Europe/Copenhagen", erl_datetime) when erl_datetime >= {{2015, 3, 29}, {3, 0, 0}} and erl_datetime < {{2015, 10, 25}, {3, 0, 0}} do @@ -171,6 +209,15 @@ defmodule FakeTimeZoneDatabase do }} end + defp time_zone_periods_from_wall("Etc/UTC", _erl_datetime) do + {:ok, + %{ + std_offset: 0, + utc_offset: 0, + zone_abbr: "UTC" + }} + end + defp time_zone_periods_from_wall(time_zone, _) when time_zone != "Europe/Copenhagen" do {:error, :time_zone_not_found} end diff --git a/lib/elixir/test/elixir/calendar/holocene.exs b/lib/elixir/test/elixir/calendar/holocene.exs index 50046323c75..668a9569af5 100644 --- a/lib/elixir/test/elixir/calendar/holocene.exs +++ b/lib/elixir/test/elixir/calendar/holocene.exs @@ -154,4 +154,20 @@ defmodule Calendar.Holocene do @impl true defdelegate iso_days_to_end_of_day(iso_days), to: Calendar.ISO + + # The Holocene calendar extends most year and day count guards implemented in the ISO calendars. + @impl true + def shift_date(_year, _month, _day, _duration) do + raise "shift_date/4 not implemented" + end + + @impl true + def shift_naive_datetime(_year, _month, _day, _hour, _minute, _second, _microsecond, _duration) do + raise "shift_naive_datetime/8 not implemented" + end + + @impl true + def shift_time(_hour, _minute, _second, _microsecond, _duration) do + raise "shift_time/5 not implemented" + end end diff --git a/lib/elixir/test/elixir/calendar/iso_test.exs b/lib/elixir/test/elixir/calendar/iso_test.exs index 87baebc7d25..25bc13fea8d 100644 --- a/lib/elixir/test/elixir/calendar/iso_test.exs +++ b/lib/elixir/test/elixir/calendar/iso_test.exs @@ -428,4 +428,228 @@ defmodule Calendar.ISOTest do {:error, :invalid_format} end end + + test "shift_date/2" do + assert Calendar.ISO.shift_date(2024, 3, 2, Duration.new!([])) == {2024, 3, 2} + assert Calendar.ISO.shift_date(2024, 3, 2, Duration.new!(year: 1)) == {2025, 3, 2} + assert Calendar.ISO.shift_date(2024, 3, 2, Duration.new!(month: 2)) == {2024, 5, 2} + assert Calendar.ISO.shift_date(2024, 3, 2, Duration.new!(week: 3)) == {2024, 3, 23} + assert Calendar.ISO.shift_date(2024, 3, 2, Duration.new!(day: 5)) == {2024, 3, 7} + + assert Calendar.ISO.shift_date(0, 1, 1, Duration.new!(month: 1)) == {0, 2, 1} + assert Calendar.ISO.shift_date(0, 1, 1, Duration.new!(year: 1)) == {1, 1, 1} + assert Calendar.ISO.shift_date(0, 1, 1, Duration.new!(year: -2, month: 2)) == {-2, 3, 1} + assert Calendar.ISO.shift_date(-4, 1, 1, Duration.new!(year: -1)) == {-5, 1, 1} + + assert Calendar.ISO.shift_date(2024, 3, 2, Duration.new!(year: 1, month: 2, week: 3, day: 5)) == + {2025, 5, 28} + + assert Calendar.ISO.shift_date(2024, 3, 2, Duration.new!(year: -1, month: -2, week: -3)) == + {2022, 12, 12} + + assert Calendar.ISO.shift_date(2020, 2, 28, Duration.new!(day: 1)) == {2020, 2, 29} + assert Calendar.ISO.shift_date(2020, 2, 29, Duration.new!(year: 1)) == {2021, 2, 28} + assert Calendar.ISO.shift_date(2024, 3, 31, Duration.new!(month: -1)) == {2024, 2, 29} + assert Calendar.ISO.shift_date(2024, 3, 31, Duration.new!(month: -2)) == {2024, 1, 31} + assert Calendar.ISO.shift_date(2024, 1, 31, Duration.new!(month: 1)) == {2024, 2, 29} + assert Calendar.ISO.shift_date(2024, 1, 31, Duration.new!(month: 2)) == {2024, 3, 31} + assert Calendar.ISO.shift_date(2024, 1, 31, Duration.new!(month: 3)) == {2024, 4, 30} + assert Calendar.ISO.shift_date(2024, 1, 31, Duration.new!(month: 4)) == {2024, 5, 31} + assert Calendar.ISO.shift_date(2024, 1, 31, Duration.new!(month: 5)) == {2024, 6, 30} + assert Calendar.ISO.shift_date(2024, 1, 31, Duration.new!(month: 6)) == {2024, 7, 31} + assert Calendar.ISO.shift_date(2024, 1, 31, Duration.new!(month: 7)) == {2024, 8, 31} + assert Calendar.ISO.shift_date(2024, 1, 31, Duration.new!(month: 8)) == {2024, 9, 30} + assert Calendar.ISO.shift_date(2024, 1, 31, Duration.new!(month: 9)) == {2024, 10, 31} + end + + test "shift_naive_datetime/2" do + assert Calendar.ISO.shift_naive_datetime( + 2024, + 3, + 2, + 0, + 0, + 0, + {0, 0}, + Duration.new!([]) + ) == {2024, 3, 2, 0, 0, 0, {0, 0}} + + assert Calendar.ISO.shift_naive_datetime( + 2000, + 1, + 1, + 0, + 0, + 0, + {0, 0}, + Duration.new!(year: 1) + ) == {2001, 1, 1, 0, 0, 0, {0, 0}} + + assert Calendar.ISO.shift_naive_datetime( + 2000, + 1, + 1, + 0, + 0, + 0, + {0, 0}, + Duration.new!(month: 1) + ) == {2000, 2, 1, 0, 0, 0, {0, 0}} + + assert Calendar.ISO.shift_naive_datetime( + 2000, + 1, + 1, + 0, + 0, + 0, + {0, 0}, + Duration.new!(month: 1, day: 28) + ) == {2000, 2, 29, 0, 0, 0, {0, 0}} + + assert Calendar.ISO.shift_naive_datetime( + 2000, + 1, + 1, + 0, + 0, + 0, + {0, 0}, + Duration.new!(month: 1, day: 30) + ) == {2000, 3, 2, 0, 0, 0, {0, 0}} + + assert Calendar.ISO.shift_naive_datetime( + 2000, + 1, + 1, + 0, + 0, + 0, + {0, 0}, + Duration.new!(month: 2, day: 29) + ) == {2000, 3, 30, 0, 0, 0, {0, 0}} + + assert Calendar.ISO.shift_naive_datetime( + 2000, + 2, + 29, + 0, + 0, + 0, + {0, 0}, + Duration.new!(year: -1) + ) == {1999, 2, 28, 0, 0, 0, {0, 0}} + + assert Calendar.ISO.shift_naive_datetime( + 2000, + 2, + 29, + 0, + 0, + 0, + {0, 0}, + Duration.new!(month: -1) + ) == {2000, 1, 29, 0, 0, 0, {0, 0}} + + assert Calendar.ISO.shift_naive_datetime( + 2000, + 2, + 29, + 0, + 0, + 0, + {0, 0}, + Duration.new!(month: -1, day: -28) + ) == {2000, 1, 1, 0, 0, 0, {0, 0}} + + assert Calendar.ISO.shift_naive_datetime( + 2000, + 2, + 29, + 0, + 0, + 0, + {0, 0}, + Duration.new!(month: -1, day: -30) + ) == {1999, 12, 30, 0, 0, 0, {0, 0}} + + assert Calendar.ISO.shift_naive_datetime( + 2000, + 2, + 29, + 0, + 0, + 0, + {0, 0}, + Duration.new!(month: -1, day: -29) + ) == {1999, 12, 31, 0, 0, 0, {0, 0}} + + assert Calendar.ISO.shift_naive_datetime( + 2000, + 1, + 1, + 0, + 0, + 0, + {0, 0}, + Duration.new!(hour: 12) + ) == {2000, 1, 1, 12, 0, 0, {0, 0}} + + assert Calendar.ISO.shift_naive_datetime( + 2000, + 1, + 1, + 0, + 0, + 0, + {0, 0}, + Duration.new!(minute: -65) + ) == {1999, 12, 31, 22, 55, 0, {0, 0}} + end + + test "shift_time/2" do + assert Calendar.ISO.shift_time(0, 0, 0, {0, 0}, Duration.new!(hour: 1)) == {1, 0, 0, {0, 0}} + assert Calendar.ISO.shift_time(0, 0, 0, {0, 0}, Duration.new!(hour: -1)) == {23, 0, 0, {0, 0}} + + assert Calendar.ISO.shift_time(0, 0, 0, {0, 0}, Duration.new!(minute: 30)) == + {0, 30, 0, {0, 0}} + + assert Calendar.ISO.shift_time(0, 0, 0, {0, 0}, Duration.new!(minute: -30)) == + {23, 30, 0, {0, 0}} + + assert Calendar.ISO.shift_time(0, 0, 0, {0, 0}, Duration.new!(second: 30)) == + {0, 0, 30, {0, 0}} + + assert Calendar.ISO.shift_time(0, 0, 0, {0, 0}, Duration.new!(second: -30)) == + {23, 59, 30, {0, 0}} + + assert Calendar.ISO.shift_time(0, 0, 0, {0, 0}, Duration.new!(microsecond: {100, 6})) == + {0, 0, 0, {100, 6}} + + assert Calendar.ISO.shift_time(0, 0, 0, {0, 0}, Duration.new!(microsecond: {-100, 6})) == + {23, 59, 59, {999_900, 6}} + + assert Calendar.ISO.shift_time(0, 0, 0, {0, 0}, Duration.new!(microsecond: {2000, 4})) == + {0, 0, 0, {2000, 4}} + + assert Calendar.ISO.shift_time(0, 0, 0, {0, 0}, Duration.new!(microsecond: {-2000, 4})) == + {23, 59, 59, {998_000, 4}} + + assert Calendar.ISO.shift_time(0, 0, 0, {3500, 6}, Duration.new!(microsecond: {-2000, 4})) == + {0, 0, 0, {1500, 4}} + + assert Calendar.ISO.shift_time(0, 0, 0, {3500, 4}, Duration.new!(minute: 5)) == + {0, 5, 0, {3500, 4}} + + assert Calendar.ISO.shift_time(0, 0, 0, {3500, 6}, Duration.new!(hour: 4)) == + {4, 0, 0, {3500, 6}} + + assert Calendar.ISO.shift_time( + 23, + 59, + 59, + {999_900, 6}, + Duration.new!(hour: 4, microsecond: {100, 6}) + ) == {4, 0, 0, {0, 6}} + end end diff --git a/lib/elixir/test/elixir/calendar/naive_datetime_test.exs b/lib/elixir/test/elixir/calendar/naive_datetime_test.exs index df37de1007f..8f7e754575e 100644 --- a/lib/elixir/test/elixir/calendar/naive_datetime_test.exs +++ b/lib/elixir/test/elixir/calendar/naive_datetime_test.exs @@ -391,4 +391,64 @@ defmodule NaiveDateTimeTest do assert NaiveDateTime.end_of_day(~N[2000-01-01 23:00:07]) == ~N[2000-01-01 23:59:59] end end + + describe "shift/2" do + naive_datetime = ~N[2000-01-01 00:00:00] + assert NaiveDateTime.shift(naive_datetime, year: 1) == ~N[2001-01-01 00:00:00] + assert NaiveDateTime.shift(naive_datetime, month: 1) == ~N[2000-02-01 00:00:00] + assert NaiveDateTime.shift(naive_datetime, week: 3) == ~N[2000-01-22 00:00:00] + assert NaiveDateTime.shift(naive_datetime, day: 2) == ~N[2000-01-03 00:00:00] + assert NaiveDateTime.shift(naive_datetime, hour: 6) == ~N[2000-01-01 06:00:00] + assert NaiveDateTime.shift(naive_datetime, minute: 30) == ~N[2000-01-01 00:30:00] + assert NaiveDateTime.shift(naive_datetime, second: 45) == ~N[2000-01-01 00:00:45] + assert NaiveDateTime.shift(naive_datetime, year: -1) == ~N[1999-01-01 00:00:00] + assert NaiveDateTime.shift(naive_datetime, month: -1) == ~N[1999-12-01 00:00:00] + assert NaiveDateTime.shift(naive_datetime, week: -1) == ~N[1999-12-25 00:00:00] + assert NaiveDateTime.shift(naive_datetime, day: -1) == ~N[1999-12-31 00:00:00] + assert NaiveDateTime.shift(naive_datetime, hour: -12) == ~N[1999-12-31 12:00:00] + assert NaiveDateTime.shift(naive_datetime, minute: -45) == ~N[1999-12-31 23:15:00] + assert NaiveDateTime.shift(naive_datetime, second: -30) == ~N[1999-12-31 23:59:30] + assert NaiveDateTime.shift(naive_datetime, year: 1, month: 2) == ~N[2001-03-01 00:00:00] + + assert NaiveDateTime.shift(naive_datetime, microsecond: {-500, 6}) == + ~N[1999-12-31 23:59:59.999500] + + assert NaiveDateTime.shift(naive_datetime, microsecond: {500, 6}) == + ~N[2000-01-01 00:00:00.000500] + + assert NaiveDateTime.shift(naive_datetime, microsecond: {100, 6}) == + ~N[2000-01-01 00:00:00.000100] + + assert NaiveDateTime.shift(naive_datetime, microsecond: {100, 4}) == + ~N[2000-01-01 00:00:00.0001] + + assert NaiveDateTime.shift(naive_datetime, month: 2, day: 3, hour: 6, minute: 15) == + ~N[2000-03-04 06:15:00] + + assert NaiveDateTime.shift(naive_datetime, + year: 1, + month: 2, + week: 3, + day: 4, + hour: 5, + minute: 6, + second: 7, + microsecond: {8, 6} + ) == ~N[2001-03-26 05:06:07.000008] + + assert NaiveDateTime.shift(naive_datetime, + year: -1, + month: -2, + week: -3, + day: -4, + hour: -5, + minute: -6, + second: -7, + microsecond: {-8, 6} + ) == ~N[1998-10-06 18:53:52.999992] + + assert_raise KeyError, ~s/key :months not found/, fn -> + NaiveDateTime.shift(naive_datetime, months: 12) + end + end end diff --git a/lib/elixir/test/elixir/calendar/time_test.exs b/lib/elixir/test/elixir/calendar/time_test.exs index 125ff55e6b8..7cf9e31d25d 100644 --- a/lib/elixir/test/elixir/calendar/time_test.exs +++ b/lib/elixir/test/elixir/calendar/time_test.exs @@ -102,4 +102,23 @@ defmodule TimeTest do Time.add(time, 1, 0) end end + + test "shift/2" do + time = ~T[00:00:00.0] + assert Time.shift(time, hour: 1) == ~T[01:00:00.0] + assert Time.shift(time, hour: 25) == ~T[01:00:00.0] + assert Time.shift(time, minute: 25) == ~T[00:25:00.0] + assert Time.shift(time, second: 50) == ~T[00:00:50.0] + assert Time.shift(time, microsecond: {150, 6}) == ~T[00:00:00.000150] + assert Time.shift(time, microsecond: {1000, 4}) == ~T[00:00:00.0010] + assert Time.shift(time, hour: 2, minute: 65, second: 5) == ~T[03:05:05.0] + + assert_raise ArgumentError, ~s/cannot shift time by date units/, fn -> + Time.shift(time, day: 1) + end + + assert_raise KeyError, ~s/key :hours not found/, fn -> + Time.shift(time, hours: 12) + end + end end