Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Duration and shift/2 for calendar types #13385

Merged
merged 97 commits into from
Apr 4, 2024
Merged
Show file tree
Hide file tree
Changes from 95 commits
Commits
Show all changes
97 commits
Select commit Hold shift + click to select a range
2ea8b44
Date.shift/2 for ISO dates
tfiedlerdejanze Mar 2, 2024
df03f59
do not order shift options
tfiedlerdejanze Mar 2, 2024
209dc13
use calendar.days_in_month/2
tfiedlerdejanze Mar 2, 2024
d1e09e4
cleanup last day of month
tfiedlerdejanze Mar 2, 2024
e275c4b
return tuple from Date.shift/2
tfiedlerdejanze Mar 2, 2024
a07b738
implement Calendar.ISO.shift_date/4 Calendar callback
tfiedlerdejanze Mar 2, 2024
8c1ff22
handle complex shifts around negative years
tfiedlerdejanze Mar 2, 2024
34803b9
more tests
tfiedlerdejanze Mar 2, 2024
f69cf9d
Introduce bare Calendar.Duration
tfiedlerdejanze Mar 3, 2024
cf41c66
build shift options from Duration.t() in calendar
tfiedlerdejanze Mar 4, 2024
54c15b7
cleanup iso shift helpers
tfiedlerdejanze Mar 4, 2024
489b18c
collapse duration time units to months, seconds and microseconds
tfiedlerdejanze Mar 4, 2024
c91f3b6
specs + iso function order
tfiedlerdejanze Mar 4, 2024
b5aaa40
separate shift_date/4 and shift_naive_datetime/8 implementations
tfiedlerdejanze Mar 4, 2024
4580d21
add tests
tfiedlerdejanze Mar 4, 2024
d81a809
add sigil_P
tfiedlerdejanze Mar 4, 2024
d1b9323
gracefully handle invalid duration args
tfiedlerdejanze Mar 4, 2024
2d5b067
add Time.shift/2
tfiedlerdejanze Mar 4, 2024
27e02fd
variable name
tfiedlerdejanze Mar 4, 2024
ce16ad3
add DateTime.shift/2
tfiedlerdejanze Mar 4, 2024
287c8af
add sigil variant to all examples
tfiedlerdejanze Mar 4, 2024
063a33c
cleanup specs
tfiedlerdejanze Mar 4, 2024
8c1e536
more typespecs
tfiedlerdejanze Mar 4, 2024
c6ab2ee
drop sigil_P
tfiedlerdejanze Mar 5, 2024
7ead281
add initial Calendar.Duration api
tfiedlerdejanze Mar 5, 2024
39e3d5d
align examples in Date module
tfiedlerdejanze Mar 5, 2024
1d17efe
slightly less verbose shift_time_unit/3
tfiedlerdejanze Mar 5, 2024
a9aa6b1
Calendar.Duration -> Duration
tfiedlerdejanze Mar 5, 2024
0566438
flatten shift_naive_datetime iso test
tfiedlerdejanze Mar 5, 2024
7852620
consistent style in duration.ex
tfiedlerdejanze Mar 6, 2024
92bc5ef
add more duration public functions
tfiedlerdejanze Mar 6, 2024
c24286b
comparison rounds to second
tfiedlerdejanze Mar 6, 2024
be1fcd6
add calendar callbacks for Duration.to_seconds/1 and Duration.from_se…
tfiedlerdejanze Mar 6, 2024
f8844a4
consider microseconds in Duration.compare/2
tfiedlerdejanze Mar 6, 2024
3b302f5
drop duration utility functions
tfiedlerdejanze Mar 6, 2024
64f8af3
let shift functions raise when called with invalid units
tfiedlerdejanze Mar 6, 2024
f78d99d
prevent shifting date by time units
tfiedlerdejanze Mar 6, 2024
f008f70
prevent shifting time by date units
tfiedlerdejanze Mar 6, 2024
bf0fbc2
spec invalid keys
tfiedlerdejanze Mar 6, 2024
75bcb42
support millisecond in Duration
tfiedlerdejanze Mar 6, 2024
65d6ee5
Duration.invalid_keys/2 -> Duration.invalid_units/2
tfiedlerdejanze Mar 6, 2024
586f4a4
consistent calendar microsecond format in duration
tfiedlerdejanze Mar 6, 2024
efbcc38
cleanup
tfiedlerdejanze Mar 6, 2024
c8e75a5
validate date and time fields Calendar.ISO
tfiedlerdejanze Mar 6, 2024
210e2b9
cleanup
tfiedlerdejanze Mar 6, 2024
28e5e03
cleanup
tfiedlerdejanze Mar 6, 2024
3121fa4
from_naive/4 in DateTime.shift/3
tfiedlerdejanze Mar 6, 2024
f4be40c
cleanup shift options validation
tfiedlerdejanze Mar 6, 2024
2fd6528
since doc annotations
tfiedlerdejanze Mar 6, 2024
af047e6
improve docs
tfiedlerdejanze Mar 6, 2024
bcacd15
dont pattern match on input struct type
tfiedlerdejanze Mar 6, 2024
1d24815
more docs
tfiedlerdejanze Mar 6, 2024
28b3b68
add convenience wrapper shift!/2 to all calendar types
tfiedlerdejanze Mar 6, 2024
7027e7f
docs
tfiedlerdejanze Mar 6, 2024
73c1151
quote functions
tfiedlerdejanze Mar 6, 2024
1bf41fd
consolidate Date.add/2 and Time.add/3
tfiedlerdejanze Mar 6, 2024
4b0a2bc
Revert "consolidate Date.add/2 and Time.add/3"
tfiedlerdejanze Mar 7, 2024
d6243f1
correct shift docs
tfiedlerdejanze Mar 7, 2024
715403a
noop implementation to test calendar callback
tfiedlerdejanze Mar 7, 2024
dadba31
raise instead of noop in Calendar.Holocene date shift test
tfiedlerdejanze Mar 7, 2024
5952c17
docs
tfiedlerdejanze Mar 7, 2024
7167711
doc structure
tfiedlerdejanze Mar 7, 2024
45b208c
docs
tfiedlerdejanze Mar 7, 2024
4536554
datetime shift time zone docs
tfiedlerdejanze Mar 8, 2024
a6fcc6e
test Calendar.ISO.shift_time/5
tfiedlerdejanze Mar 8, 2024
7adf6fc
drop redundant clause in shift_months/2
tfiedlerdejanze Mar 9, 2024
b5142cd
consistent annotation
tfiedlerdejanze Mar 9, 2024
1696f3c
concise hint on add/2
tfiedlerdejanze Mar 9, 2024
86f088b
DateTime.shift/3 as coordinated universal time
tfiedlerdejanze Mar 11, 2024
c8e904a
specs
tfiedlerdejanze Mar 11, 2024
1200e27
apply offset after wall clock shift
tfiedlerdejanze Mar 12, 2024
c0b4217
respect duration precision in DateTime.shift/3
tfiedlerdejanze Mar 12, 2024
ebf48b1
return calendar type instead of tuple
tfiedlerdejanze Mar 13, 2024
f522fd9
docs
tfiedlerdejanze Mar 14, 2024
6337efa
separate DateTime.shift/3 clause for UTC
tfiedlerdejanze Mar 14, 2024
9ba2a04
actually test PDT and PST
tfiedlerdejanze Mar 14, 2024
4867289
DateTime.shift/3 docs
tfiedlerdejanze Mar 22, 2024
d3cee29
correct "since" annotations
tfiedlerdejanze Mar 27, 2024
e4d74fa
ensure two-element tuple for Duration.new/1 microsecond
tfiedlerdejanze Mar 28, 2024
6641b4d
validate all duration units in Duration.new/1
tfiedlerdejanze Mar 28, 2024
8f4fd81
keep validation simple
tfiedlerdejanze Mar 28, 2024
d2a016b
cleanup Duration.new/1 validation
tfiedlerdejanze Mar 28, 2024
8799b15
cleanup datetime test
tfiedlerdejanze Mar 28, 2024
3d148dd
leaner docs
tfiedlerdejanze Mar 29, 2024
b771016
duration validation
tfiedlerdejanze Apr 3, 2024
5e9d6fb
add examples for DateTime.shift/3
tfiedlerdejanze Apr 4, 2024
d605e0f
add more examples to Duration
tfiedlerdejanze Apr 4, 2024
0f8957e
add leap year examples to Date and NaiveDateTime
tfiedlerdejanze Apr 4, 2024
bbce345
quote KeyError in docs
tfiedlerdejanze Apr 4, 2024
92b5eac
consistent note on shift in add docs
tfiedlerdejanze Apr 4, 2024
454f054
consolidate shift docs to mention the default calendar
tfiedlerdejanze Apr 4, 2024
3e02be7
improve docs for DateTime.shift/3
tfiedlerdejanze Apr 4, 2024
7ecbc48
add shift examples with negative duration units
tfiedlerdejanze Apr 4, 2024
8f9c1f4
rename Duration.unit type to Duration.unit_pair
tfiedlerdejanze Apr 4, 2024
14b1314
document rounding behaviour when shifting by month
tfiedlerdejanze Apr 4, 2024
369f953
rename Duration.new/1 to Duration.new!/1
tfiedlerdejanze Apr 4, 2024
f69baf1
inspect only non-default values on %Duration{}
tfiedlerdejanze Apr 4, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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}
tfiedlerdejanze marked this conversation as resolved.
Show resolved Hide resolved

@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]
josevalim marked this conversation as resolved.
Show resolved Hide resolved

# 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
Loading