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

Add DateTime/NaiveDateTime.beginning_of_day and DateTime/NaiveDateTime.end_of_day #12443

Merged
merged 6 commits into from Mar 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 12 additions & 0 deletions lib/elixir/lib/calendar.ex
Expand Up @@ -326,6 +326,18 @@ defmodule Calendar do
{:ok, {year, month, day, hour, minute, second, microsecond}, utc_offset}
| {:error, atom}

@doc """
Converts the given `t:iso_days/0` to the first moment of the day.
"""
@doc since: "1.15.0"
@callback iso_days_to_beginning_of_day(iso_days) :: iso_days

@doc """
Converts the given `t:iso_days/0` to the last moment of the day.
"""
@doc since: "1.15.0"
@callback iso_days_to_end_of_day(iso_days) :: iso_days

# General Helpers

@doc """
Expand Down
40 changes: 40 additions & 0 deletions lib/elixir/lib/calendar/iso.ex
Expand Up @@ -1401,6 +1401,46 @@ defmodule Calendar.ISO do
"-" <> zero_pad(-val, count)
end

@doc """
Converts the `t:Calendar.iso_days/0` to the first moment of the day.

## Examples

iex> Calendar.ISO.iso_days_to_beginning_of_day({0, {0, 86400000000}})
{0, {0, 86400000000}}
iex> Calendar.ISO.iso_days_to_beginning_of_day({730485, {43200000000, 86400000000}})
{730485, {0, 86400000000}}
iex> Calendar.ISO.iso_days_to_beginning_of_day({730485, {46800000000, 86400000000}})
{730485, {0, 86400000000}}

"""
@doc since: "1.15.0"
@impl true
@spec iso_days_to_beginning_of_day(Calendar.iso_days()) :: Calendar.iso_days()
def iso_days_to_beginning_of_day({days, _day_fraction}) do
{days, {0, @parts_per_day}}
end

@doc """
Converts the `t:Calendar.iso_days/0` to the last moment of the day.

## Examples

iex> Calendar.ISO.iso_days_to_end_of_day({0, {0, 86400000000}})
{0, {86399999999, 86400000000}}
iex> Calendar.ISO.iso_days_to_end_of_day({730485, {43200000000, 86400000000}})
{730485, {86399999999, 86400000000}}
iex> Calendar.ISO.iso_days_to_end_of_day({730485, {46800000000, 86400000000}})
{730485, {86399999999, 86400000000}}

"""
@doc since: "1.15.0"
@impl true
@spec iso_days_to_end_of_day(Calendar.iso_days()) :: Calendar.iso_days()
def iso_days_to_end_of_day({days, _day_fraction}) do
{days, {@parts_per_day - 1, @parts_per_day}}
end

## Helpers

@doc false
Expand Down
54 changes: 54 additions & 0 deletions lib/elixir/lib/calendar/naive_datetime.ex
Expand Up @@ -1169,6 +1169,60 @@ defmodule NaiveDateTime do
end
end

@doc """
Calculates a `NaiveDateTime` that is the first moment for the given `NaiveDateTime`.

To calculate the beginning of day of a `DateTime`, call this function, then convert back to a `DateTime`:

datetime
|> NaiveDateTime.beginning_of_day()
|> DateTime.from_naive(datetime.timezone)
josevalim marked this conversation as resolved.
Show resolved Hide resolved

Note that the beginning of the day may not exist or be ambiguous
in a given timezone, so you must handle those cases accordingly.

## Examples

iex> NaiveDateTime.beginning_of_day(~N[2000-01-01 23:00:07.123456])
~N[2000-01-01 00:00:00.000000]

"""
@doc since: "1.15.0"
@spec beginning_of_day(Calendar.naive_datetime()) :: t
def beginning_of_day(%{calendar: calendar, microsecond: {_, precision}} = naive_datetime) do
naive_datetime
|> to_iso_days()
|> calendar.iso_days_to_beginning_of_day()
|> from_iso_days(calendar, precision)
end

@doc """
Calculates a `NaiveDateTime` that is the last moment for the given `NaiveDateTime`.

To calculate the end of day of a `DateTime`, call this function, then convert back to a `DateTime`:

datetime
|> NaiveDateTime.beginning_of_day()
|> DateTime.from_naive(datetime.timezone)
josevalim marked this conversation as resolved.
Show resolved Hide resolved

Note that the end of the day may not exist or be ambiguous
in a given timezone, so you must handle those cases accordingly.

## Examples

iex> NaiveDateTime.end_of_day(~N[2000-01-01 23:00:07.123456])
~N[2000-01-01 23:59:59.999999]

"""
@doc since: "1.15.0"
@spec end_of_day(Calendar.naive_datetime()) :: t
def end_of_day(%{calendar: calendar, microsecond: {_, precision}} = naive_datetime) do
naive_datetime
|> to_iso_days()
|> calendar.iso_days_to_end_of_day()
|> from_iso_days(calendar, precision)
end

## Helpers

defp seconds_from_day_fraction({parts_in_day, @seconds_per_day}),
Expand Down
6 changes: 6 additions & 0 deletions lib/elixir/test/elixir/calendar/holocene.exs
Expand Up @@ -148,4 +148,10 @@ defmodule Calendar.Holocene do

@impl true
defdelegate valid_time?(hour, minute, second, microsecond), to: Calendar.ISO

@impl true
defdelegate iso_days_to_beginning_of_day(iso_days), to: Calendar.ISO

@impl true
defdelegate iso_days_to_end_of_day(iso_days), to: Calendar.ISO
end
35 changes: 35 additions & 0 deletions lib/elixir/test/elixir/calendar/naive_datetime_test.exs
Expand Up @@ -348,4 +348,39 @@ defmodule NaiveDateTimeTest do
assert catch_error(NaiveDateTime.to_time(~T[00:00:00.000000]))
end
end

describe "beginning_of_day/1" do
test "precision" do
assert NaiveDateTime.beginning_of_day(~N[2000-01-01 23:00:07.123]) ==
~N[2000-01-01 00:00:00.000]

assert NaiveDateTime.beginning_of_day(~N[2000-01-01 23:00:07]) == ~N[2000-01-01 00:00:00]
end
end

describe "end_of_day/1" do
test "precision" do
assert NaiveDateTime.end_of_day(~N[2000-01-01 23:00:07.123]) == %NaiveDateTime{
calendar: Calendar.ISO,
day: 1,
hour: 23,
microsecond: {999_999, 3},
minute: 59,
month: 1,
second: 59,
year: 2000
}

assert NaiveDateTime.end_of_day(~N[2000-01-01 23:00:07]) == %NaiveDateTime{
calendar: Calendar.ISO,
day: 1,
hour: 23,
microsecond: {999_999, 0},
Copy link
Member

Choose a reason for hiding this comment

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

the fact it's {999_999, 0} might lead to surprising behaviour:

import ExUnit.Assertions
start = ~N[2023-01-01 00:00:00]
assert NaiveDateTime.end_of_day(start) == 
       start |> NaiveDateTime.add(1, :day) |> NaiveDateTime.add(-1, :second)
** (ExUnit.AssertionError)

Assertion with == failed
code:  assert NaiveDateTime.end_of_day(start) ==
              start |> NaiveDateTime.add(1, :day) |> NaiveDateTime.add(-1, :second)
left:  ~N[2023-01-01 23:59:59]
right: ~N[2023-01-01 23:59:59]

the difference is:

iex> NaiveDateTime.end_of_day(start).microsecond
{999999, 0}

iex> start |> NaiveDateTime.add(1, :day) |> NaiveDateTime.add(-1, :second) |> Map.fetch!(:microsecond)
{0, 0}

We'd see similar behaviour for different precisions e.g. milliseconds.

I'm honestly not sure what should be the behaviour here.

Copy link
Member

Choose a reason for hiding this comment

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

one way to look at it is we should be able to write the assertion under question using just sigils:

assert NaiveDateTime.end_of_day(~N[2000-01-01 23:00:07]) == ~N[2000-01-01 23:59:59]

and the fact we can't is a reason to revisit this?

Copy link
Member

Choose a reason for hiding this comment

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

I created a new issue. We should probably truncate it based on the precision: {(10 ** precision) - 1, precision}

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 just opened a PR to fix this #12450.

minute: 59,
month: 1,
second: 59,
year: 2000
}
end
end
end