Skip to content

Commit 79c19dc

Browse files
tfiedlerdejanzejosevalim
authored andcommitted
Add Duration.from_iso8601/1 (#13473)
1 parent 772c7b0 commit 79c19dc

File tree

3 files changed

+196
-2
lines changed

3 files changed

+196
-2
lines changed

lib/elixir/lib/calendar/duration.ex

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,4 +283,52 @@ defmodule Duration do
283283
microsecond: {-ms, p}
284284
}
285285
end
286+
287+
@doc """
288+
Parses an ISO 8601 formatted duration string to a `Duration` struct.
289+
290+
A decimal fraction may be specified for seconds only, using either a comma or a full stop.
291+
292+
## Examples
293+
294+
iex> Duration.from_iso8601("P1Y2M3DT4H5M6S")
295+
{:ok, %Duration{year: 1, month: 2, day: 3, hour: 4, minute: 5, second: 6}}
296+
iex> Duration.from_iso8601("PT10H30M")
297+
{:ok, %Duration{hour: 10, minute: 30, second: 0}}
298+
iex> Duration.from_iso8601("P3Y-2MT3H")
299+
{:ok, %Duration{year: 3, month: -2, hour: 3}}
300+
iex> Duration.from_iso8601("P1YT4.650S")
301+
{:ok, %Duration{year: 1, second: 4, microsecond: {650000, 3}}}
302+
303+
"""
304+
@spec from_iso8601(String.t()) :: {:ok, t} | {:error, atom}
305+
def from_iso8601(string) when is_binary(string) do
306+
case Calendar.ISO.parse_duration(string) do
307+
{:ok, duration} ->
308+
{:ok, new!(duration)}
309+
310+
error ->
311+
error
312+
end
313+
end
314+
315+
@doc """
316+
Same as `from_iso8601/1` but raises an ArgumentError.
317+
318+
## Examples
319+
320+
iex> Duration.from_iso8601!("P1Y2M3DT4H5M6S")
321+
%Duration{year: 1, month: 2, day: 3, hour: 4, minute: 5, second: 6}
322+
323+
"""
324+
@spec from_iso8601!(String.t()) :: t
325+
def from_iso8601!(string) when is_binary(string) do
326+
case from_iso8601(string) do
327+
{:ok, duration} ->
328+
duration
329+
330+
{:error, reason} ->
331+
raise ArgumentError, ~s/failed to parse duration "#{string}". reason: #{inspect(reason)}/
332+
end
333+
end
286334
end

lib/elixir/lib/calendar/iso.ex

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ defmodule Calendar.ISO do
1818
1919
The standard library supports a minimal set of possible ISO 8601 features.
2020
Specifically, the parser only supports calendar dates and does not support
21-
ordinal and week formats.
21+
ordinal and week formats. Additionally, it supports parsing ISO 8601
22+
formatted durations, including negative time units and fractional seconds.
2223
2324
By default Elixir only parses extended-formatted date/times. You can opt-in
2425
to parse basic-formatted date/times.
@@ -29,7 +30,7 @@ defmodule Calendar.ISO do
2930
3031
Elixir does not support reduced accuracy formats (for example, a date without
3132
the day component) nor decimal precisions in the lowest component (such as
32-
`10:01:25,5`). No functions exist to parse ISO 8601 durations or time intervals.
33+
`10:01:25,5`).
3334
3435
#### Examples
3536
@@ -663,6 +664,65 @@ defmodule Calendar.ISO do
663664
end
664665
end
665666

667+
@doc """
668+
Parses an ISO 8601 formatted duration string to a list of `Duration` compabitble unit pairs.
669+
670+
See `Duration.from_iso8601/1`.
671+
"""
672+
@doc since: "1.17.0"
673+
@spec parse_duration(String.t()) :: {:ok, [Duration.unit_pair()]} | {:error, atom()}
674+
def parse_duration("P" <> string) when byte_size(string) > 0 do
675+
parse_duration_date(string, [], year: ?Y, month: ?M, week: ?W, day: ?D)
676+
end
677+
678+
def parse_duration(_) do
679+
{:error, :invalid_duration}
680+
end
681+
682+
defp parse_duration_date("", acc, _allowed), do: {:ok, acc}
683+
684+
defp parse_duration_date("T" <> string, acc, _allowed) when byte_size(string) > 0 do
685+
parse_duration_time(string, acc, hour: ?H, minute: ?M, second: ?S)
686+
end
687+
688+
defp parse_duration_date(string, acc, allowed) do
689+
with {integer, <<next, rest::binary>>} <- Integer.parse(string),
690+
{key, allowed} <- find_unit(allowed, next) do
691+
parse_duration_date(rest, [{key, integer} | acc], allowed)
692+
else
693+
_ -> {:error, :invalid_date_component}
694+
end
695+
end
696+
697+
defp parse_duration_time("", acc, _allowed), do: {:ok, acc}
698+
699+
defp parse_duration_time(string, acc, allowed) do
700+
case Integer.parse(string) do
701+
{second, <<delimiter, _::binary>> = rest} when delimiter in [?., ?,] ->
702+
case parse_microsecond(rest) do
703+
{{ms, precision}, "S"} ->
704+
ms = if second > 0, do: ms, else: -ms
705+
{:ok, [second: second, microsecond: {ms, precision}] ++ acc}
706+
707+
_ ->
708+
{:error, :invalid_time_component}
709+
end
710+
711+
{integer, <<next, rest::binary>>} ->
712+
case find_unit(allowed, next) do
713+
{key, allowed} -> parse_duration_time(rest, [{key, integer} | acc], allowed)
714+
false -> {:error, :invalid_time_component}
715+
end
716+
717+
_ ->
718+
{:error, :invalid_time_component}
719+
end
720+
end
721+
722+
defp find_unit([{key, unit} | rest], unit), do: {key, rest}
723+
defp find_unit([_ | rest], unit), do: find_unit(rest, unit)
724+
defp find_unit([], _unit), do: false
725+
666726
@doc """
667727
Returns the `t:Calendar.iso_days/0` format of the specified date.
668728

lib/elixir/test/elixir/calendar/duration_test.exs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,4 +220,90 @@ defmodule DurationTest do
220220
microsecond: {0, 0}
221221
}
222222
end
223+
224+
test "from_iso8601/1" do
225+
assert Duration.from_iso8601("P1Y2M3DT4H5M6S") ==
226+
{:ok, %Duration{year: 1, month: 2, day: 3, hour: 4, minute: 5, second: 6}}
227+
228+
assert Duration.from_iso8601("P3WT5H3M") == {:ok, %Duration{week: 3, hour: 5, minute: 3}}
229+
assert Duration.from_iso8601("PT5H3M") == {:ok, %Duration{hour: 5, minute: 3}}
230+
assert Duration.from_iso8601("P1Y2M3D") == {:ok, %Duration{year: 1, month: 2, day: 3}}
231+
assert Duration.from_iso8601("PT4H5M6S") == {:ok, %Duration{hour: 4, minute: 5, second: 6}}
232+
assert Duration.from_iso8601("P1Y2M") == {:ok, %Duration{year: 1, month: 2}}
233+
assert Duration.from_iso8601("P3D") == {:ok, %Duration{day: 3}}
234+
assert Duration.from_iso8601("PT4H5M") == {:ok, %Duration{hour: 4, minute: 5}}
235+
assert Duration.from_iso8601("PT6S") == {:ok, %Duration{second: 6}}
236+
assert Duration.from_iso8601("P2M4Y") == {:error, :invalid_date_component}
237+
assert Duration.from_iso8601("P4Y2W3Y") == {:error, :invalid_date_component}
238+
assert Duration.from_iso8601("P5HT4MT3S") == {:error, :invalid_date_component}
239+
assert Duration.from_iso8601("P5H3HT4M") == {:error, :invalid_date_component}
240+
assert Duration.from_iso8601("PT1D") == {:error, :invalid_time_component}
241+
assert Duration.from_iso8601("PT.6S") == {:error, :invalid_time_component}
242+
assert Duration.from_iso8601("invalid") == {:error, :invalid_duration}
243+
end
244+
245+
test "from_iso8601!/1" do
246+
assert Duration.from_iso8601!("P1Y2M3DT4H5M6S") == %Duration{
247+
year: 1,
248+
month: 2,
249+
day: 3,
250+
hour: 4,
251+
minute: 5,
252+
second: 6
253+
}
254+
255+
assert Duration.from_iso8601!("P3WT5H3M") == %Duration{week: 3, hour: 5, minute: 3}
256+
assert Duration.from_iso8601!("PT5H3M") == %Duration{hour: 5, minute: 3}
257+
assert Duration.from_iso8601!("P1Y2M3D") == %Duration{year: 1, month: 2, day: 3}
258+
assert Duration.from_iso8601!("PT4H5M6S") == %Duration{hour: 4, minute: 5, second: 6}
259+
assert Duration.from_iso8601!("P1Y2M") == %Duration{year: 1, month: 2}
260+
assert Duration.from_iso8601!("P3D") == %Duration{day: 3}
261+
assert Duration.from_iso8601!("PT4H5M") == %Duration{hour: 4, minute: 5}
262+
assert Duration.from_iso8601!("PT6S") == %Duration{second: 6}
263+
assert Duration.from_iso8601!("PT1,6S") == %Duration{second: 1, microsecond: {600_000, 1}}
264+
assert Duration.from_iso8601!("PT-1.6S") == %Duration{second: -1, microsecond: {-600_000, 1}}
265+
266+
assert Duration.from_iso8601!("PT-1.234567S") == %Duration{
267+
second: -1,
268+
microsecond: {-234_567, 6}
269+
}
270+
271+
assert Duration.from_iso8601!("PT1.12345678S") == %Duration{
272+
second: 1,
273+
microsecond: {123_456, 6}
274+
}
275+
276+
assert Duration.from_iso8601!("P3Y4W-3DT-6S") == %Duration{
277+
year: 3,
278+
week: 4,
279+
day: -3,
280+
second: -6
281+
}
282+
283+
assert Duration.from_iso8601!("PT-4.23S") == %Duration{second: -4, microsecond: {-230_000, 2}}
284+
285+
assert_raise ArgumentError,
286+
~s/failed to parse duration "P5H3HT4M". reason: :invalid_date_component/,
287+
fn ->
288+
Duration.from_iso8601!("P5H3HT4M")
289+
end
290+
291+
assert_raise ArgumentError,
292+
~s/failed to parse duration "P4Y2W3Y". reason: :invalid_date_component/,
293+
fn ->
294+
Duration.from_iso8601!("P4Y2W3Y")
295+
end
296+
297+
assert_raise ArgumentError,
298+
~s/failed to parse duration "invalid". reason: :invalid_duration/,
299+
fn ->
300+
Duration.from_iso8601!("invalid")
301+
end
302+
303+
assert_raise ArgumentError,
304+
~s/failed to parse duration "P4.5YT6S". reason: :invalid_date_component/,
305+
fn ->
306+
Duration.from_iso8601!("P4.5YT6S")
307+
end
308+
end
223309
end

0 commit comments

Comments
 (0)