Skip to content

Commit 01a6c23

Browse files
feat: constrain the sign of a duration (#2868)
* feat: constrain the sign of a duration * improvement: accept a single unit, and use ash's own option types
1 parent 33f3f0c commit 01a6c23

2 files changed

Lines changed: 130 additions & 3 deletions

File tree

lib/ash/type/duration.ex

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,20 @@ defmodule Ash.Type.Duration do
77
@day_time_units [:week, :day, :hour, :minute, :second, :microsecond]
88
@duration_units @year_month_units ++ @day_time_units
99

10+
@signs [:positive, :negative, :zero]
11+
1012
@constraints [
13+
signs: [
14+
type: {:wrap_list, {:one_of, @signs}},
15+
doc: """
16+
The signs the value may have, compared against zero by `Ash.Type.Duration.compare/2`. Any combination is permitted: `:positive` or `[:positive]` requires a positive duration, `[:positive, :zero]` a non-negative one, and `[:positive, :negative]` a non-zero one. Omit the constraint to allow any sign. This is the sign of the duration as a whole, not of each unit — `%Duration{day: 1, hour: -5}` is positive, being nineteen hours. Only where the year/month and week/day sides carry opposite signs does the comparison depend on `compare/2`'s 30-day month.
17+
"""
18+
],
1119
units: [
12-
type: {:or, [{:in, [:year_month, :day_time]}, {:list, {:in, @duration_units}}]},
20+
type:
21+
{:or, [{:one_of, [:year_month, :day_time]}, {:wrap_list, {:one_of, @duration_units}}]},
1322
doc: """
14-
The units the value may be expressed in. A duration is always re-expressed in the largest of these units that will hold it, on the way in and on the way out, so `[:week, :hour]` turns `1 week 1 day 5 hours` into `1 week 29 hours`. A value that no combination of the permitted units expresses exactly is rejected — including anything that would have to cross the year/month to week/day boundary, which no conversion can. This applies on the way out as well as in: a stored duration the permitted units cannot express is refused rather than quietly rewritten. Either an explicit list of units, or a shorthand for one side of that boundary: `:year_month` (`[:year, :month]`) or `:day_time` (`[:week, :day, :hour, :minute, :second, :microsecond]`). Confining an attribute to a single side keeps its values comparable (see `Ash.Type.Duration.compare/2`). With no constraint every unit is permitted, so the same normalization applies and nothing is ever lost.
23+
The units the value may be expressed in. A duration is always re-expressed in the largest of these units that will hold it, on the way in and on the way out, so `[:week, :hour]` turns `1 week 1 day 5 hours` into `1 week 29 hours`. A value that no combination of the permitted units expresses exactly is rejected — including anything that would have to cross the year/month to week/day boundary, which no conversion can. This applies on the way out as well as in: a stored duration the permitted units cannot express is refused rather than quietly rewritten. Either a single unit, an explicit list of them, or a shorthand for one side of that boundary: `:year_month` (`[:year, :month]`) or `:day_time` (`[:week, :day, :hour, :minute, :second, :microsecond]`). Confining an attribute to a single side keeps its values comparable (see `Ash.Type.Duration.compare/2`). With no constraint every unit is permitted, so the same normalization applies and nothing is ever lost.
1524
"""
1625
]
1726
]
@@ -79,7 +88,7 @@ defmodule Ash.Type.Duration do
7988

8089
case disallowed_units(normalized, allowed) do
8190
[] ->
82-
{:ok, normalized}
91+
check_sign(normalized, constraints[:signs])
8392

8493
disallowed ->
8594
{:error,
@@ -93,12 +102,43 @@ defmodule Ash.Type.Duration do
93102
end
94103
end
95104

105+
# A magnitude constraint, where `units` is a representation one. Normalizing preserves
106+
# magnitude, so the two are independent.
107+
defp check_sign(value, nil), do: {:ok, value}
108+
109+
defp check_sign(value, permitted) do
110+
# `wrap_list` normalizes at init, but constraints also arrive here directly.
111+
permitted = List.wrap(permitted)
112+
113+
if sign(value) in permitted do
114+
{:ok, value}
115+
else
116+
{:error,
117+
[
118+
[
119+
message: "must be %{signs}",
120+
signs: Enum.map_join(permitted, " or ", &to_string/1),
121+
sign: to_string(sign(value))
122+
]
123+
]}
124+
end
125+
end
126+
127+
defp sign(%Duration{} = value) do
128+
case compare(value, %Duration{}) do
129+
:gt -> :positive
130+
:lt -> :negative
131+
:eq -> :zero
132+
end
133+
end
134+
96135
# No `units` constraint permits every unit.
97136
defp permitted_units(nil), do: @duration_units
98137
defp permitted_units(units), do: expand_units(units)
99138

100139
defp expand_units(:year_month), do: @year_month_units
101140
defp expand_units(:day_time), do: @day_time_units
141+
defp expand_units(unit) when is_atom(unit), do: [unit]
102142
defp expand_units(units) when is_list(units), do: units
103143

104144
defp disallowed_units(%Duration{} = value, allowed),

test/type/duration_test.exs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,14 @@ defmodule Ash.Test.Type.DurationTest do
229229
assert disallowed =~ "month"
230230
end
231231

232+
test "a single unit may be given without a list" do
233+
assert {:ok, Duration.new!(day: 3)} ==
234+
Ash.Type.Duration.apply_constraints(Duration.new!(hour: 72), units: :day)
235+
236+
assert {:error, _} =
237+
Ash.Type.Duration.apply_constraints(Duration.new!(hour: 73), units: :day)
238+
end
239+
232240
test "the :day_time shorthand accepts day/time units and rejects year/month" do
233241
assert {:ok, _} =
234242
Ash.Type.Duration.apply_constraints(Duration.new!(day: 3, hour: 4),
@@ -608,6 +616,85 @@ defmodule Ash.Test.Type.DurationTest do
608616
end
609617
end
610618

619+
describe "signs constraint" do
620+
test "with no constraint, any sign is permitted" do
621+
for d <- [Duration.new!(hour: 1), Duration.new!(hour: -1), Duration.new!([])] do
622+
assert {:ok, _} = Ash.Type.Duration.apply_constraints(d, [])
623+
end
624+
end
625+
626+
test "a single sign permits only itself" do
627+
assert {:ok, _} =
628+
Ash.Type.Duration.apply_constraints(Duration.new!(hour: 1), signs: [:positive])
629+
630+
assert {:error, _} =
631+
Ash.Type.Duration.apply_constraints(Duration.new!(hour: -1), signs: [:positive])
632+
633+
assert {:error, _} =
634+
Ash.Type.Duration.apply_constraints(Duration.new!([]), signs: [:positive])
635+
end
636+
637+
test "combinations say the useful things" do
638+
non_negative = [signs: [:positive, :zero]]
639+
non_zero = [signs: [:positive, :negative]]
640+
641+
assert {:ok, _} = Ash.Type.Duration.apply_constraints(Duration.new!(hour: 1), non_negative)
642+
assert {:ok, _} = Ash.Type.Duration.apply_constraints(Duration.new!([]), non_negative)
643+
644+
assert {:error, _} =
645+
Ash.Type.Duration.apply_constraints(Duration.new!(hour: -1), non_negative)
646+
647+
assert {:ok, _} = Ash.Type.Duration.apply_constraints(Duration.new!(hour: 1), non_zero)
648+
assert {:ok, _} = Ash.Type.Duration.apply_constraints(Duration.new!(hour: -1), non_zero)
649+
assert {:error, _} = Ash.Type.Duration.apply_constraints(Duration.new!([]), non_zero)
650+
end
651+
652+
test "the sign is the duration's, not each unit's" do
653+
# a day less five hours is nineteen hours, so positive
654+
assert {:ok, _} =
655+
Ash.Type.Duration.apply_constraints(Duration.new!(day: 1, hour: -5),
656+
signs: [:positive]
657+
)
658+
659+
# a week less ten days is three days short, so negative
660+
assert {:error, _} =
661+
Ash.Type.Duration.apply_constraints(Duration.new!(week: 1, day: -10),
662+
signs: [:positive]
663+
)
664+
end
665+
666+
test "it is checked on read as well as write" do
667+
assert {:error, _} =
668+
Ash.Type.Duration.cast_stored(Duration.new!(hour: -1), signs: [:positive])
669+
670+
assert {:ok, _} = Ash.Type.Duration.cast_stored(Duration.new!(hour: 1), signs: [:positive])
671+
end
672+
673+
test "it composes with units, which cannot change a magnitude" do
674+
constraints = [units: [:hour], signs: [:positive]]
675+
676+
# normalized to hour: 36, still positive
677+
assert {:ok, Duration.new!(hour: 36)} ==
678+
Ash.Type.Duration.apply_constraints(Duration.new!(second: 129_600), constraints)
679+
680+
# units are satisfiable but the sign is not
681+
assert {:error, _} =
682+
Ash.Type.Duration.apply_constraints(Duration.new!(hour: -36), constraints)
683+
end
684+
685+
test "a bare atom means the same as a one-element list" do
686+
assert {:ok, _} =
687+
Ash.Type.Duration.apply_constraints(Duration.new!(hour: 1), signs: :positive)
688+
689+
assert {:error, _} =
690+
Ash.Type.Duration.apply_constraints(Duration.new!(hour: -1), signs: :positive)
691+
end
692+
693+
test "nil passes regardless" do
694+
assert {:ok, nil} = Ash.Type.Duration.apply_constraints(nil, signs: [:positive])
695+
end
696+
end
697+
611698
describe "the year/month to week/day divide" do
612699
test "a year is never expressed in days, whatever units are permitted" do
613700
assert {:ok, Duration.new!(year: 1)} ==

0 commit comments

Comments
 (0)