Skip to content

Commit

Permalink
Implement Duration and shift/2 for calendar types (#13385)
Browse files Browse the repository at this point in the history
  • Loading branch information
tfiedlerdejanze committed Apr 4, 2024
1 parent e26e9d8 commit 38f62a8
Show file tree
Hide file tree
Showing 16 changed files with 1,483 additions and 6 deletions.
28 changes: 28 additions & 0 deletions lib/elixir/lib/calendar.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 """
Expand Down
55 changes: 54 additions & 1 deletion lib/elixir/lib/calendar/date.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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).
"""
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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}}
Expand Down
155 changes: 155 additions & 0 deletions lib/elixir/lib/calendar/datetime.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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`).
Expand Down

0 comments on commit 38f62a8

Please sign in to comment.