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 68 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.
Jump to
Jump to file
Failed to load files.
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
69 changes: 68 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)
{:ok, ~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`, use `Date.shift/2`.
josevalim marked this conversation as resolved.
Show resolved Hide resolved

## Examples

iex> Date.add(~D[2000-01-03], -2)
Expand Down Expand Up @@ -757,6 +762,68 @@ defmodule Date do
end
end

@doc """
Shifts given `date` by `duration` according to its calendar.

Allowed units are: `:year, :month, :week, :day`.

When used with the default calendar `Calendar.ISO`:

Durations are collapsed before they are applied:
- 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

Durations are applied in order of the size of the unit: `month > day`.
josevalim marked this conversation as resolved.
Show resolved Hide resolved

Raises ArgumentError when called with time scale units.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can see someone having a Duration with a mix of date-scale and time-scale units wanting to use this function and discarding the time-scale units. E.g. 2 days, 5 minutes, 3 seconds. And you just want to shift by 2 days ignoring the minutes and seconds.

A new function called something like Duration.truncate_time(duration)could be called before calling Date.shift/2.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am afraid this could cause for some confusion, e.g. if called with a duration of 86400 seconds, the caller might expect that the date got shifted by a day, while the function would have silently done nothing. Since we have a shift function per calendar type, i feel it's ok to expect from the caller to pass a fitting duration depending on which type they're working with.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may add Duration.truncate_time_units in the future and Duration.truncate_date_units though. It can be the topic of a future pull request.


## Examples

iex> Date.shift(~D[2016-01-03], month: 2)
{:ok, ~D[2016-03-03]}
iex> Date.shift(~D[2016-01-30], month: 1)
{:ok, ~D[2016-02-29]}
iex> Date.shift(~D[2016-01-31], year: 4, day: 1)
{:ok, ~D[2020-02-01]}
iex> Date.shift(~D[2016-01-03], Duration.new(month: 2))
{:ok, ~D[2016-03-03]}

"""
@doc since: "1.7.0"
tfiedlerdejanze marked this conversation as resolved.
Show resolved Hide resolved
@spec shift(Calendar.date(), Duration.t() | [Duration.unit()]) :: {:ok, 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)
{:ok, %Date{calendar: calendar, year: year, month: month, day: day}}
end

def shift(date, duration) do
shift(date, Duration.new(duration))
end

@doc """
Shifts given `date` by `duration` according to its calendar.

Same as `shift/2` but raises RuntimeError.

## Examples

iex> Date.shift!(~D[2016-01-03], month: 2)
~D[2016-03-03]

"""
@doc since: "1.7.0"
@spec shift(Calendar.date(), Duration.t() | [Duration.unit()]) :: t
def shift!(date, duration) do
case shift(date, duration) do
{:ok, date} ->
date

reason ->
raise RuntimeError, "cannot shift date, reason: #{inspect(reason)}"
end
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
128 changes: 128 additions & 0 deletions lib/elixir/lib/calendar/datetime.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1610,6 +1610,7 @@ defmodule DateTime do
iex> result.microsecond
{21000, 3}

To shift a datetime by a `Duration`, use `DateTime.shift/3`.
josevalim marked this conversation as resolved.
Show resolved Hide resolved
"""
@doc since: "1.8.0"
@spec add(
Expand Down Expand Up @@ -1674,6 +1675,133 @@ 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`.

When it comes to time zones, this function is equivalent to shifting the wall time
(the time a person at said timezone would see on a clock) by the given duration.
Then we validate if the shifted wall time is valid. For example, in time zones that
observe "Daylight Saving Time", a particular wall time may not exist when moving
the clock forward or be ambiguous when moving the clock back.

Check `from_naive/3` for more information on the possible result types.

When used with the default calendar `Calendar.ISO`:

Durations are collapsed before they are applied:
- 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

Durations are applied in order of the size of the unit: `month > day > second`.

## Examples

iex> DateTime.shift(~U[2016-01-01 00:00:00Z], month: 2)
{:ok, ~U[2016-03-01 00:00:00Z]}
iex> DateTime.shift(~U[2016-01-01 00:00:00Z], year: 1, week: 4)
{:ok, ~U[2017-01-29 00:00:00Z]}
iex> DateTime.shift(~U[2016-01-01 00:00:00Z], minute: 25)
{:ok, ~U[2016-01-01 00:25:00Z]}
iex> DateTime.shift(~U[2016-01-01 00:00:00Z], minute: 5, microsecond: {500, 4})
{:ok, ~U[2016-01-01 00:05:00.0005Z]}
"""
@doc since: "1.7.0"
tfiedlerdejanze marked this conversation as resolved.
Show resolved Hide resolved
@spec shift(
Calendar.datetime(),
Duration.t() | [Duration.unit()],
Calendar.time_zone_database()
) ::
{:ok, t}
| {:ambiguous, first_datetime :: t, second_datetime :: t}
| {:gap, t, t}
| {:error,
:incompatible_calendars
| :time_zone_not_found
| :utc_only_time_zone_database}
def shift(datetime, duration, time_zone_database \\ Calendar.get_time_zone_database())

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,
time_zone: time_zone
} = datetime

{year, month, day, hour, minute, second, microsecond} =
calendar.shift_naive_datetime(
year,
month,
day,
hour,
minute,
second,
microsecond,
duration
)

from_naive(
%NaiveDateTime{
calendar: calendar,
year: year,
month: month,
day: day,
hour: hour,
minute: minute,
second: second,
microsecond: microsecond
},
time_zone,
time_zone_database
)
end
tfiedlerdejanze marked this conversation as resolved.
Show resolved Hide resolved

def shift(datetime, duration, time_zone_database) do
shift(datetime, Duration.new(duration), time_zone_database)
end

@doc """
Shifts given `datetime` by `duration` according to its calendar.

Same as `shift/2` but raises ArgumentError.

## Examples

iex> DateTime.shift!(~U[2016-01-01 00:00:00Z], month: 2)
~U[2016-03-01 00:00:00Z]

"""
@doc since: "1.7.0"
@spec shift!(Calendar.datetime(), Duration.t() | [Duration.unit()]) :: t
def shift!(%{time_zone: time_zone} = datetime, duration) do
case shift(datetime, duration) do
{:ok, datetime} ->
datetime

{:ambiguous, dt1, dt2} ->
raise ArgumentError,
"cannot shift datetime #{inspect(datetime)} by #{inspect(duration)} because the result " <>
"is ambiguous in time zone #{time_zone} as there is an overlap " <>
"between #{inspect(dt1)} and #{inspect(dt2)}"

{:gap, dt1, dt2} ->
raise ArgumentError,
"cannot shift datetime #{inspect(datetime)} by #{inspect(duration)} because the result " <>
"does not exist in time zone #{time_zone} as there is a gap " <>
"between #{inspect(dt1)} and #{inspect(dt2)}"

{:error, reason} ->
raise ArgumentError,
"cannot shift datetime #{inspect(datetime)} by #{inspect(duration)}, reason: #{inspect(reason)}"
end
end

@doc """
Returns the given datetime with the microsecond field truncated to the given
precision (`:microsecond`, `:millisecond` or `:second`).
Expand Down