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 Duration.from_iso8601/1 #13473

Merged
merged 20 commits into from
May 27, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
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
2 changes: 2 additions & 0 deletions lib/elixir/lib/calendar/date.ex
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,8 @@ defmodule Date do

## Examples

iex> Date.shift(~D[2016-01-31], ~P[P4Y1D])
~D[2020-02-01]
iex> Date.shift(~D[2016-01-03], month: 2)
~D[2016-03-03]
iex> Date.shift(~D[2016-01-30], month: -1)
Expand Down
2 changes: 2 additions & 0 deletions lib/elixir/lib/calendar/datetime.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1711,6 +1711,8 @@ defmodule DateTime do

## Examples

iex> DateTime.shift(~U[2016-01-01 00:00:00Z], ~P[P1Y4W])
~U[2017-01-29 00:00:00Z]
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)
Expand Down
140 changes: 140 additions & 0 deletions lib/elixir/lib/calendar/duration.ex
Original file line number Diff line number Diff line change
Expand Up @@ -197,4 +197,144 @@ defmodule Duration do
microsecond: {-ms, p}
}
end

@doc """
Parses an ISO 8601-2 formatted duration string to a `Duration` struct.
tfiedlerdejanze marked this conversation as resolved.
Show resolved Hide resolved

## Examples

iex> Duration.parse("P1Y2M3DT4H5M6S")
{:ok, %Duration{year: 1, month: 2, day: 3, hour: 4, minute: 5, second: 6}}
iex> Duration.parse("PT10H30M")
{:ok, %Duration{hour: 10, minute: 30, second: 0}}
iex> Duration.parse("P3Y-2MT3H")
{:ok, %Duration{year: 3, month: -2, hour: 3}}
iex> Duration.parse("-P3Y2MT3H")
{:ok, %Duration{year: -3, month: -2, hour: -3}}
iex> Duration.parse("-P3Y-2MT3H")
{:ok, %Duration{year: -3, month: 2, hour: -3}}

"""
@spec parse(String.t()) :: {:ok, t} | {:error, String.t()}
def parse("P" <> duration_string) do
tfiedlerdejanze marked this conversation as resolved.
Show resolved Hide resolved
parse(duration_string, %{}, "", false)
end

def parse("-P" <> duration_string) do
case parse(duration_string, %{}, "", false) do
{:ok, duration} ->
{:ok, negate(duration)}

error ->
error
end
end

def parse(_) do
{:error, "invalid duration string"}
tfiedlerdejanze marked this conversation as resolved.
Show resolved Hide resolved
end

@doc """
Same as parse/1 but raises an ArgumentError.

## Examples

iex> Duration.parse!("P1Y2M3DT4H5M6S")
%Duration{year: 1, month: 2, day: 3, hour: 4, minute: 5, second: 6}

"""
@spec parse!(String.t()) :: t
def parse!(duration_string) do
case parse(duration_string) do
{:ok, duration} ->
duration

{:error, reason} ->
raise ArgumentError, "failed to parse duration. reason: #{inspect(reason)}"
end
end

defp parse(<<>>, duration, "", _), do: {:ok, new!(Enum.into(duration, []))}

defp parse(<<c::utf8, rest::binary>>, duration, buffer, is_time)
when c in ?0..?9 or c in [?., ?-] do
parse(rest, duration, <<buffer::binary, c::utf8>>, is_time)
end
tfiedlerdejanze marked this conversation as resolved.
Show resolved Hide resolved

defp parse(<<"Y", rest::binary>>, duration, buffer, false) do
parse(:year, rest, duration, buffer, false)
end

defp parse(<<"M", rest::binary>>, duration, buffer, false) do
parse(:month, rest, duration, buffer, false)
end

defp parse(<<"W", rest::binary>>, duration, buffer, false) do
parse(:week, rest, duration, buffer, false)
end

defp parse(<<"D", rest::binary>>, duration, buffer, false) do
parse(:day, rest, duration, buffer, false)
end

defp parse(<<"T", _::binary>>, _duration, _, true) do
{:error, "time delimiter was already provided"}
end

defp parse(<<"T", rest::binary>>, duration, _buffer, false) do
parse(rest, duration, "", true)
end
tfiedlerdejanze marked this conversation as resolved.
Show resolved Hide resolved

defp parse(<<"H", rest::binary>>, duration, buffer, true) do
parse(:hour, rest, duration, buffer, true)
end

defp parse(<<"M", rest::binary>>, duration, buffer, true) do
parse(:minute, rest, duration, buffer, true)
end

defp parse(<<"S", rest::binary>>, duration, buffer, true) do
parse(:second, rest, duration, buffer, true)
end

defp parse(<<c::utf8, _::binary>>, _, _, _) do
{:error, "unexpected character: #{<<c>>}"}
end

defp parse(unit, _string, duration, _buffer, _is_time) when is_map_key(duration, unit) do
{:error, "#{unit} was already provided"}
end

defp parse(:second, string, duration, buffer, is_time) do
case Float.parse(buffer) do
tfiedlerdejanze marked this conversation as resolved.
Show resolved Hide resolved
{float_second, ""} ->
second = trunc(float_second)

{microsecond, precision} =
case trunc((float_second - second) * 1_000_000) do
0 -> {0, 0}
microsecond -> {microsecond, 6}
end

duration =
duration
|> Map.put(:second, second)
|> Map.put(:microsecond, {microsecond, precision})

parse(string, duration, "", is_time)

_ ->
{:error, "invalid value for second: #{buffer}"}
end
end

defp parse(unit, string, duration, buffer, is_time) do
case Integer.parse(buffer) do
{duration_value, ""} ->
parse(string, Map.put(duration, unit, duration_value), "", is_time)

_ ->
{:error, "invalid value for #{unit}: #{buffer}"}
end
end
end
2 changes: 2 additions & 0 deletions lib/elixir/lib/calendar/naive_datetime.ex
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,8 @@ defmodule NaiveDateTime do

## Examples

iex> NaiveDateTime.shift(~N[2016-01-31 00:00:00], ~P[P4Y1D])
~N[2020-02-01 00:00:00]
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)
Expand Down
2 changes: 2 additions & 0 deletions lib/elixir/lib/calendar/time.ex
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,8 @@ defmodule Time do

## Examples

iex> Time.shift(~T[01:15:00], ~P[PT6H15M])
~T[07:30:00]
iex> Time.shift(~T[01:00:15], hour: 12)
~T[13:00:15]
iex> Time.shift(~T[01:35:00], hour: 6, minute: -15)
Expand Down
25 changes: 25 additions & 0 deletions lib/elixir/lib/kernel.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6655,6 +6655,31 @@ defmodule Kernel do
end
end

@doc ~S"""
Handles the sigil `~P` to create a `Duration`.

## Examples

iex> ~P[P1Y2M3DT4H5M6S]
%Duration{year: 1, month: 2, day: 3, hour: 4, minute: 5, second: 6}
iex> ~P[PT4H1.5S]
%Duration{hour: 4, second: 1, microsecond: {500000, 6}}
iex> ~P[P-1Y3WT4H]
%Duration{year: -1, week: 3, hour: 4}
iex> ~P[-P1Y3WT4H]
%Duration{year: -1, week: -3, hour: -4}
iex> ~P[-P1Y-3WT4H]
%Duration{year: -1, week: 3, hour: -4}

"""
defmacro sigil_P(duration_string, modifiers)

defmacro sigil_P({:<<>>, _, [duration_string]}, []) do
quote do
Duration.parse!(unquote(duration_string))
end
end

@doc ~S"""
Handles the sigil `~w` for list of words.

Expand Down
12 changes: 12 additions & 0 deletions lib/elixir/pages/getting-started/sigils.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,18 @@ iex> time_zone
"Etc/UTC"
```

### Duration

A [%Duration{}](`Duration`) struct represents a collection of time scale units.
The `~P` sigil allows developers to create Durations from an ISO 8601-2 formatted duration string:

```elixir
iex> ~P[P1Y2M3DT4H5M6S]
%Duration{year: 1, month: 2, day: 3, hour: 4, minute: 5, second: 6}
iex> ~P[-P1Y-3WT4H-1.5S]
%Duration{year: -1, week: 3, hour: -4, second: 1, microsecond: {500000, 6}}
```

## Custom sigils

As hinted at the beginning of this chapter, sigils in Elixir are extensible. In fact, using the sigil `~r/foo/i` is equivalent to calling `sigil_r` with a binary and a char list as the argument:
Expand Down
98 changes: 98 additions & 0 deletions lib/elixir/test/elixir/calendar/duration_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -217,4 +217,102 @@ defmodule DurationTest do
microsecond: {0, 0}
}
end

test "parse/1" do
tfiedlerdejanze marked this conversation as resolved.
Show resolved Hide resolved
assert Duration.parse("P1Y2M3DT4H5M6S") ==
{:ok, %Duration{year: 1, month: 2, day: 3, hour: 4, minute: 5, second: 6}}

assert Duration.parse("P3WT5H3M") == {:ok, %Duration{week: 3, hour: 5, minute: 3}}
assert Duration.parse("PT5H3M") == {:ok, %Duration{hour: 5, minute: 3}}
assert Duration.parse("P1Y2M3D") == {:ok, %Duration{year: 1, month: 2, day: 3}}
assert Duration.parse("PT4H5M6S") == {:ok, %Duration{hour: 4, minute: 5, second: 6}}
assert Duration.parse("P1Y2M") == {:ok, %Duration{year: 1, month: 2}}
assert Duration.parse("P3D") == {:ok, %Duration{day: 3}}
assert Duration.parse("PT4H5M") == {:ok, %Duration{hour: 4, minute: 5}}
assert Duration.parse("PT6S") == {:ok, %Duration{second: 6}}
assert Duration.parse("P5H3HT4M") == {:error, "unexpected character: H"}
assert Duration.parse("P4Y2W3Y") == {:error, "year was already provided"}
assert Duration.parse("invalid") == {:error, "invalid duration string"}
end

test "parse!/1" do
assert Duration.parse!("P1Y2M3DT4H5M6S") == %Duration{
year: 1,
month: 2,
day: 3,
hour: 4,
minute: 5,
second: 6
}

assert Duration.parse!("P3WT5H3M") == %Duration{week: 3, hour: 5, minute: 3}
assert Duration.parse!("PT5H3M") == %Duration{hour: 5, minute: 3}
assert Duration.parse!("P1Y2M3D") == %Duration{year: 1, month: 2, day: 3}
assert Duration.parse!("PT4H5M6S") == %Duration{hour: 4, minute: 5, second: 6}
assert Duration.parse!("P1Y2M") == %Duration{year: 1, month: 2}
assert Duration.parse!("P3D") == %Duration{day: 3}
assert Duration.parse!("PT4H5M") == %Duration{hour: 4, minute: 5}
assert Duration.parse!("PT6S") == %Duration{second: 6}
assert Duration.parse!("PT1.6S") == %Duration{second: 1, microsecond: {600_000, 6}}
assert Duration.parse!("PT1.12345678S") == %Duration{second: 1, microsecond: {123_456, 6}}
assert Duration.parse!("P3Y4W-3DT-6S") == %Duration{year: 3, week: 4, day: -3, second: -6}
assert Duration.parse!("PT-4.23S") == %Duration{second: -4, microsecond: {-230_000, 6}}
assert Duration.parse!("-P3WT5H3M") == %Duration{week: -3, hour: -5, minute: -3}
assert Duration.parse!("-P-3WT5H3M") == %Duration{week: 3, hour: -5, minute: -3}

assert_raise ArgumentError,
~s/failed to parse duration. reason: "unexpected character: H"/,
fn ->
Duration.parse!("P5H3HT4M")
end

assert_raise ArgumentError,
~s/failed to parse duration. reason: "year was already provided"/,
fn ->
Duration.parse!("P4Y2W3Y")
end

assert_raise ArgumentError,
~s/failed to parse duration. reason: "invalid duration string"/,
fn ->
Duration.parse!("invalid")
end

assert_raise ArgumentError,
~s/failed to parse duration. reason: "invalid value for year: 4.5"/,
fn ->
Duration.parse!("P4.5YT6S")
end
end

test "sigil_P" do
assert ~P[P1Y2M3DT4H5M6S] == %Duration{
year: 1,
month: 2,
day: 3,
hour: 4,
minute: 5,
second: 6
}

assert ~P[PT5H3M] == %Duration{hour: 5, minute: 3}
assert ~P[P1Y2M3D] == %Duration{year: 1, month: 2, day: 3}
assert ~P[PT4H5M6S] == %Duration{hour: 4, minute: 5, second: 6}
assert ~P[P1Y2M] == %Duration{year: 1, month: 2}
assert ~P[P3D] == %Duration{day: 3}
assert ~P[PT4H5M] == %Duration{hour: 4, minute: 5}
assert ~P[PT6S] == %Duration{second: 6}

assert_raise ArgumentError,
~s/failed to parse duration. reason: "unexpected character: H"/,
fn ->
Code.eval_string("~P[P5H3HT4M]")
end

assert_raise ArgumentError,
~s/failed to parse duration. reason: "invalid duration string"/,
fn ->
Code.eval_string("~P[invalid]")
end
end
end