Add Duration.from_iso8601/1 (#13473)
This commit is contained in:
committed by
José Valim
parent
772c7b0884
commit
79c19dcb04
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user