Add Duration.from_iso8601/1 (#13473)

This commit is contained in:
Theodor Fiedler
2024-05-27 11:48:07 +02:00
committed by José Valim
parent 772c7b0884
commit 79c19dcb04
3 changed files with 196 additions and 2 deletions
+48
View File
@@ -283,4 +283,52 @@ defmodule Duration do
microsecond: {-ms, p}
}
end
@doc """
Parses an ISO 8601 formatted duration string to a `Duration` struct.
A decimal fraction may be specified for seconds only, using either a comma or a full stop.
## Examples
iex> Duration.from_iso8601("P1Y2M3DT4H5M6S")
{:ok, %Duration{year: 1, month: 2, day: 3, hour: 4, minute: 5, second: 6}}
iex> Duration.from_iso8601("PT10H30M")
{:ok, %Duration{hour: 10, minute: 30, second: 0}}
iex> Duration.from_iso8601("P3Y-2MT3H")
{:ok, %Duration{year: 3, month: -2, hour: 3}}
iex> Duration.from_iso8601("P1YT4.650S")
{:ok, %Duration{year: 1, second: 4, microsecond: {650000, 3}}}
"""
@spec from_iso8601(String.t()) :: {:ok, t} | {:error, atom}
def from_iso8601(string) when is_binary(string) do
case Calendar.ISO.parse_duration(string) do
{:ok, duration} ->
{:ok, new!(duration)}
error ->
error
end
end
@doc """
Same as `from_iso8601/1` but raises an ArgumentError.
## Examples
iex> Duration.from_iso8601!("P1Y2M3DT4H5M6S")
%Duration{year: 1, month: 2, day: 3, hour: 4, minute: 5, second: 6}
"""
@spec from_iso8601!(String.t()) :: t
def from_iso8601!(string) when is_binary(string) do
case from_iso8601(string) do
{:ok, duration} ->
duration
{:error, reason} ->
raise ArgumentError, ~s/failed to parse duration "#{string}". reason: #{inspect(reason)}/
end
end
end
+62 -2
View File
@@ -18,7 +18,8 @@ defmodule Calendar.ISO do
The standard library supports a minimal set of possible ISO 8601 features.
Specifically, the parser only supports calendar dates and does not support
ordinal and week formats.
ordinal and week formats. Additionally, it supports parsing ISO 8601
formatted durations, including negative time units and fractional seconds.
By default Elixir only parses extended-formatted date/times. You can opt-in
to parse basic-formatted date/times.
@@ -29,7 +30,7 @@ defmodule Calendar.ISO do
Elixir does not support reduced accuracy formats (for example, a date without
the day component) nor decimal precisions in the lowest component (such as
`10:01:25,5`). No functions exist to parse ISO 8601 durations or time intervals.
`10:01:25,5`).
#### Examples
@@ -663,6 +664,65 @@ defmodule Calendar.ISO do
end
end
@doc """
Parses an ISO 8601 formatted duration string to a list of `Duration` compabitble unit pairs.
See `Duration.from_iso8601/1`.
"""
@doc since: "1.17.0"
@spec parse_duration(String.t()) :: {:ok, [Duration.unit_pair()]} | {:error, atom()}
def parse_duration("P" <> string) when byte_size(string) > 0 do
parse_duration_date(string, [], year: ?Y, month: ?M, week: ?W, day: ?D)
end
def parse_duration(_) do
{:error, :invalid_duration}
end
defp parse_duration_date("", acc, _allowed), do: {:ok, acc}
defp parse_duration_date("T" <> string, acc, _allowed) when byte_size(string) > 0 do
parse_duration_time(string, acc, hour: ?H, minute: ?M, second: ?S)
end
defp parse_duration_date(string, acc, allowed) do
with {integer, <<next, rest::binary>>} <- Integer.parse(string),
{key, allowed} <- find_unit(allowed, next) do
parse_duration_date(rest, [{key, integer} | acc], allowed)
else
_ -> {:error, :invalid_date_component}
end
end
defp parse_duration_time("", acc, _allowed), do: {:ok, acc}
defp parse_duration_time(string, acc, allowed) do
case Integer.parse(string) do
{second, <<delimiter, _::binary>> = rest} when delimiter in [?., ?,] ->
case parse_microsecond(rest) do
{{ms, precision}, "S"} ->
ms = if second > 0, do: ms, else: -ms
{:ok, [second: second, microsecond: {ms, precision}] ++ acc}
_ ->
{:error, :invalid_time_component}
end
{integer, <<next, rest::binary>>} ->
case find_unit(allowed, next) do
{key, allowed} -> parse_duration_time(rest, [{key, integer} | acc], allowed)
false -> {:error, :invalid_time_component}
end
_ ->
{:error, :invalid_time_component}
end
end
defp find_unit([{key, unit} | rest], unit), do: {key, rest}
defp find_unit([_ | rest], unit), do: find_unit(rest, unit)
defp find_unit([], _unit), do: false
@doc """
Returns the `t:Calendar.iso_days/0` format of the specified date.
@@ -220,4 +220,90 @@ defmodule DurationTest do
microsecond: {0, 0}
}
end
test "from_iso8601/1" do
assert Duration.from_iso8601("P1Y2M3DT4H5M6S") ==
{:ok, %Duration{year: 1, month: 2, day: 3, hour: 4, minute: 5, second: 6}}
assert Duration.from_iso8601("P3WT5H3M") == {:ok, %Duration{week: 3, hour: 5, minute: 3}}
assert Duration.from_iso8601("PT5H3M") == {:ok, %Duration{hour: 5, minute: 3}}
assert Duration.from_iso8601("P1Y2M3D") == {:ok, %Duration{year: 1, month: 2, day: 3}}
assert Duration.from_iso8601("PT4H5M6S") == {:ok, %Duration{hour: 4, minute: 5, second: 6}}
assert Duration.from_iso8601("P1Y2M") == {:ok, %Duration{year: 1, month: 2}}
assert Duration.from_iso8601("P3D") == {:ok, %Duration{day: 3}}
assert Duration.from_iso8601("PT4H5M") == {:ok, %Duration{hour: 4, minute: 5}}
assert Duration.from_iso8601("PT6S") == {:ok, %Duration{second: 6}}
assert Duration.from_iso8601("P2M4Y") == {:error, :invalid_date_component}
assert Duration.from_iso8601("P4Y2W3Y") == {:error, :invalid_date_component}
assert Duration.from_iso8601("P5HT4MT3S") == {:error, :invalid_date_component}
assert Duration.from_iso8601("P5H3HT4M") == {:error, :invalid_date_component}
assert Duration.from_iso8601("PT1D") == {:error, :invalid_time_component}
assert Duration.from_iso8601("PT.6S") == {:error, :invalid_time_component}
assert Duration.from_iso8601("invalid") == {:error, :invalid_duration}
end
test "from_iso8601!/1" do
assert Duration.from_iso8601!("P1Y2M3DT4H5M6S") == %Duration{
year: 1,
month: 2,
day: 3,
hour: 4,
minute: 5,
second: 6
}
assert Duration.from_iso8601!("P3WT5H3M") == %Duration{week: 3, hour: 5, minute: 3}
assert Duration.from_iso8601!("PT5H3M") == %Duration{hour: 5, minute: 3}
assert Duration.from_iso8601!("P1Y2M3D") == %Duration{year: 1, month: 2, day: 3}
assert Duration.from_iso8601!("PT4H5M6S") == %Duration{hour: 4, minute: 5, second: 6}
assert Duration.from_iso8601!("P1Y2M") == %Duration{year: 1, month: 2}
assert Duration.from_iso8601!("P3D") == %Duration{day: 3}
assert Duration.from_iso8601!("PT4H5M") == %Duration{hour: 4, minute: 5}
assert Duration.from_iso8601!("PT6S") == %Duration{second: 6}
assert Duration.from_iso8601!("PT1,6S") == %Duration{second: 1, microsecond: {600_000, 1}}
assert Duration.from_iso8601!("PT-1.6S") == %Duration{second: -1, microsecond: {-600_000, 1}}
assert Duration.from_iso8601!("PT-1.234567S") == %Duration{
second: -1,
microsecond: {-234_567, 6}
}
assert Duration.from_iso8601!("PT1.12345678S") == %Duration{
second: 1,
microsecond: {123_456, 6}
}
assert Duration.from_iso8601!("P3Y4W-3DT-6S") == %Duration{
year: 3,
week: 4,
day: -3,
second: -6
}
assert Duration.from_iso8601!("PT-4.23S") == %Duration{second: -4, microsecond: {-230_000, 2}}
assert_raise ArgumentError,
~s/failed to parse duration "P5H3HT4M". reason: :invalid_date_component/,
fn ->
Duration.from_iso8601!("P5H3HT4M")
end
assert_raise ArgumentError,
~s/failed to parse duration "P4Y2W3Y". reason: :invalid_date_component/,
fn ->
Duration.from_iso8601!("P4Y2W3Y")
end
assert_raise ArgumentError,
~s/failed to parse duration "invalid". reason: :invalid_duration/,
fn ->
Duration.from_iso8601!("invalid")
end
assert_raise ArgumentError,
~s/failed to parse duration "P4.5YT6S". reason: :invalid_date_component/,
fn ->
Duration.from_iso8601!("P4.5YT6S")
end
end
end