Skip to content

Commit 4328b09

Browse files
authored
Add Duration.to_string (#14028)
1 parent 41832ba commit 4328b09

File tree

4 files changed

+198
-9
lines changed

4 files changed

+198
-9
lines changed

lib/elixir/lib/calendar/datetime.ex

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1030,9 +1030,12 @@ defmodule DateTime do
10301030
By default, `DateTime.to_iso8601/2` returns datetimes formatted in the "extended"
10311031
format, for human readability. It also supports the "basic" format through passing the `:basic` option.
10321032
1033-
Only supports converting datetimes which are in the ISO calendar,
1034-
attempting to convert datetimes from other calendars will raise.
10351033
You can also optionally specify an offset for the formatted string.
1034+
If none is given, the one in the given `datetime` is used.
1035+
1036+
Only supports converting datetimes which are in the ISO calendar.
1037+
If another calendar is given, it is automatically converted to ISO.
1038+
It raises if not possible.
10361039
10371040
WARNING: the ISO 8601 datetime format does not contain the time zone nor
10381041
its abbreviation, which means information is lost when converting to such
@@ -1346,6 +1349,11 @@ defmodule DateTime do
13461349
@doc """
13471350
Converts the given `datetime` to a string according to its calendar.
13481351
1352+
Unfortunately, there is no standard that specifies rendering of a
1353+
datetime with its complete time zone information, so Elixir uses a
1354+
custom (but relatively common) representation which appends the time
1355+
zone abbreviation and full name to the datetime.
1356+
13491357
### Examples
13501358
13511359
iex> dt = %DateTime{year: 2000, month: 2, day: 29, zone_abbr: "CET",

lib/elixir/lib/calendar/duration.ex

Lines changed: 114 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,114 @@ defmodule Duration do
355355
end
356356
end
357357

358+
@doc """
359+
Converts the given `duration` to a human readable representation.
360+
361+
## Options
362+
363+
* `:units` - the units to be used alongside each duration component.
364+
The default units follow the ISO 80000-3 standard:
365+
366+
[
367+
year: "a",
368+
month: "mo",
369+
week: "wk",
370+
day: "d",
371+
hour: "h",
372+
minute: "min",
373+
second: "s"
374+
]
375+
376+
* `:separator` - a string used to separate the distinct components. Defaults to `" "`.
377+
378+
## Examples
379+
380+
iex> Duration.to_string(Duration.new!(second: 30))
381+
"30s"
382+
iex> Duration.to_string(Duration.new!(day: 40, hour: 12, minute: 42, second: 12))
383+
"40d 12h 42min 12s"
384+
385+
By default, this function uses ISO 80000-3 units, which uses "a" for years.
386+
But you can customize all units via the units option:
387+
388+
iex> Duration.to_string(Duration.new!(year: 3))
389+
"3a"
390+
iex> Duration.to_string(Duration.new!(year: 3), units: [year: "y"])
391+
"3y"
392+
393+
You may also choose the separator:
394+
395+
iex> Duration.to_string(Duration.new!(day: 40, hour: 12, minute: 42, second: 12), separator: ", ")
396+
"40d, 12h, 42min, 12s"
397+
398+
A duration without components is rendered as "0s":
399+
400+
iex> Duration.to_string(Duration.new!([]))
401+
"0s"
402+
403+
Microseconds are rendered as part of seconds with the appropriate precision:
404+
405+
iex> Duration.to_string(Duration.new!(second: 1, microsecond: {2_200, 3}))
406+
"1.002s"
407+
iex> Duration.to_string(Duration.new!(second: 1, microsecond: {-1_200_000, 4}))
408+
"-0.2000s"
409+
410+
"""
411+
@doc since: "1.18.0"
412+
def to_string(%Duration{} = duration, opts \\ []) do
413+
units = Keyword.get(opts, :units, [])
414+
separator = Keyword.get(opts, :separator, " ")
415+
416+
case to_string_year(duration, [], units) do
417+
[] ->
418+
"0" <> Keyword.get(units, :second, "s")
419+
420+
[part] ->
421+
IO.iodata_to_binary(part)
422+
423+
parts ->
424+
parts |> Enum.reduce(&[&1, separator | &2]) |> IO.iodata_to_binary()
425+
end
426+
end
427+
428+
defp to_string_part(0, _units, _key, _default, acc),
429+
do: acc
430+
431+
defp to_string_part(x, units, key, default, acc),
432+
do: [[Integer.to_string(x) | Keyword.get(units, key, default)] | acc]
433+
434+
defp to_string_year(%{year: year} = duration, acc, units) do
435+
to_string_month(duration, to_string_part(year, units, :year, "a", acc), units)
436+
end
437+
438+
defp to_string_month(%{month: month} = duration, acc, units) do
439+
to_string_week(duration, to_string_part(month, units, :month, "mo", acc), units)
440+
end
441+
442+
defp to_string_week(%{week: week} = duration, acc, units) do
443+
to_string_day(duration, to_string_part(week, units, :week, "wk", acc), units)
444+
end
445+
446+
defp to_string_day(%{day: day} = duration, acc, units) do
447+
to_string_hour(duration, to_string_part(day, units, :day, "d", acc), units)
448+
end
449+
450+
defp to_string_hour(%{hour: hour} = duration, acc, units) do
451+
to_string_minute(duration, to_string_part(hour, units, :hour, "h", acc), units)
452+
end
453+
454+
defp to_string_minute(%{minute: minute} = duration, acc, units) do
455+
to_string_second(duration, to_string_part(minute, units, :minute, "min", acc), units)
456+
end
457+
458+
defp to_string_second(%{second: 0, microsecond: {0, _}}, acc, _units) do
459+
acc
460+
end
461+
462+
defp to_string_second(%{second: s, microsecond: {ms, p}}, acc, units) do
463+
[[second_component(s, ms, p) | Keyword.get(units, :second, "s")] | acc]
464+
end
465+
358466
@doc """
359467
Converts the given `duration` to an [ISO 8601-2:2019](https://en.wikipedia.org/wiki/ISO_8601) formatted string.
360468
@@ -406,15 +514,15 @@ defmodule Duration do
406514
[]
407515
end
408516

409-
defp second_component(%{second: 0, microsecond: {_, 0}}) do
410-
~c"0S"
517+
defp second_component(%{second: second, microsecond: {ms, p}}) do
518+
[second_component(second, ms, p), ?S]
411519
end
412520

413-
defp second_component(%{second: second, microsecond: {_, 0}}) do
414-
[Integer.to_string(second), ?S]
521+
defp second_component(second, _ms, 0) do
522+
Integer.to_string(second)
415523
end
416524

417-
defp second_component(%{second: second, microsecond: {ms, p}}) do
525+
defp second_component(second, ms, p) do
418526
total_ms = second * @microseconds_per_second + ms
419527
second = total_ms |> div(@microseconds_per_second) |> abs()
420528
ms = total_ms |> rem(@microseconds_per_second) |> abs()
@@ -424,8 +532,7 @@ defmodule Duration do
424532
sign,
425533
Integer.to_string(second),
426534
?.,
427-
ms |> Integer.to_string() |> String.pad_leading(6, "0") |> binary_part(0, p),
428-
?S
535+
ms |> Integer.to_string() |> String.pad_leading(6, "0") |> binary_part(0, p)
429536
]
430537
end
431538

lib/elixir/lib/calendar/naive_datetime.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,6 +759,9 @@ defmodule NaiveDateTime do
759759
@doc """
760760
Converts the given naive datetime to a string according to its calendar.
761761
762+
For redability, this function follows the RFC3339 suggestion of removing
763+
the "T" separator between the date and time components.
764+
762765
### Examples
763766
764767
iex> NaiveDateTime.to_string(~N[2000-02-28 23:00:13])

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

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,4 +358,75 @@ defmodule DurationTest do
358358
assert %Duration{microsecond: {-800_000, 0}} |> Duration.to_iso8601() == "PT0S"
359359
assert %Duration{microsecond: {-1_200_000, 2}} |> Duration.to_iso8601() == "PT-1.20S"
360360
end
361+
362+
test "to_string/1" do
363+
assert Duration.to_string(%Duration{year: 1, month: 2, day: 3, hour: 4, minute: 5, second: 6}) ==
364+
"1a 2mo 3d 4h 5min 6s"
365+
366+
assert Duration.to_string(%Duration{week: 3, hour: 5, minute: 3}) ==
367+
"3wk 5h 3min"
368+
369+
assert Duration.to_string(%Duration{hour: 5, minute: 3}) ==
370+
"5h 3min"
371+
372+
assert Duration.to_string(%Duration{year: 1, month: 2, day: 3}) ==
373+
"1a 2mo 3d"
374+
375+
assert Duration.to_string(%Duration{hour: 4, minute: 5, second: 6}) ==
376+
"4h 5min 6s"
377+
378+
assert Duration.to_string(%Duration{year: 1, month: 2}) ==
379+
"1a 2mo"
380+
381+
assert Duration.to_string(%Duration{day: 3}) ==
382+
"3d"
383+
384+
assert Duration.to_string(%Duration{hour: 4, minute: 5}) ==
385+
"4h 5min"
386+
387+
assert Duration.to_string(%Duration{second: 6}) ==
388+
"6s"
389+
390+
assert Duration.to_string(%Duration{second: 1, microsecond: {600_000, 1}}) ==
391+
"1.6s"
392+
393+
assert Duration.to_string(%Duration{second: -1, microsecond: {-600_000, 1}}) ==
394+
"-1.6s"
395+
396+
assert Duration.to_string(%Duration{second: -1, microsecond: {-234_567, 6}}) ==
397+
"-1.234567s"
398+
399+
assert Duration.to_string(%Duration{second: 1, microsecond: {123_456, 6}}) ==
400+
"1.123456s"
401+
402+
assert Duration.to_string(%Duration{year: 3, week: 4, day: -3, second: -6}) ==
403+
"3a 4wk -3d -6s"
404+
405+
assert Duration.to_string(%Duration{second: -4, microsecond: {-230_000, 2}}) ==
406+
"-4.23s"
407+
408+
assert Duration.to_string(%Duration{second: -4, microsecond: {230_000, 2}}) ==
409+
"-3.77s"
410+
411+
assert Duration.to_string(%Duration{second: 2, microsecond: {-1_200_000, 4}}) ==
412+
"0.8000s"
413+
414+
assert Duration.to_string(%Duration{second: 1, microsecond: {-1_200_000, 3}}) ==
415+
"-0.200s"
416+
417+
assert Duration.to_string(%Duration{microsecond: {-800_000, 2}}) ==
418+
"-0.80s"
419+
420+
assert Duration.to_string(%Duration{microsecond: {-800_000, 0}}) ==
421+
"0s"
422+
423+
assert Duration.to_string(%Duration{microsecond: {-1_200_000, 2}}) ==
424+
"-1.20s"
425+
426+
assert Duration.to_string(%Duration{year: 1, month: 2, day: 3, hour: 4, minute: 5, second: 6},
427+
units: [year: "year", month: "month", day: "day"],
428+
separator: "-"
429+
) ==
430+
"1year-2month-3day-4h-5min-6s"
431+
end
361432
end

0 commit comments

Comments
 (0)