Skip to content

Commit ed302e3

Browse files
committed
Merge branch 'improve-collection-specifications'
2 parents 49da4d8 + 59ea047 commit ed302e3

4 files changed

Lines changed: 183 additions & 15 deletions

File tree

lib/norm.ex

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -548,7 +548,19 @@ defmodule Norm do
548548
iex> conform!([:a, :b, :c], coll_of(spec(is_atom())))
549549
[:a, :b, :c]
550550
"""
551+
@default_opts [
552+
distinct: false,
553+
min_count: 0,
554+
max_count: :infinity,
555+
into: [],
556+
]
557+
551558
def coll_of(spec, opts \\ []) do
559+
opts = Keyword.merge(@default_opts, opts)
560+
if opts[:min_count] > opts[:max_count] do
561+
raise ArgumentError, "min_count cannot be larger than max_count"
562+
end
563+
552564
Collection.new(spec, opts)
553565
end
554566

@@ -561,8 +573,8 @@ defmodule Norm do
561573
%{a: 1, b: 2, c: 3}
562574
"""
563575
def map_of(kpred, vpred, opts \\ []) do
564-
opts = Keyword.merge(opts, kind: :map)
565-
Collection.new({kpred, vpred}, opts)
576+
opts = Keyword.merge(opts, into: %{})
577+
coll_of({kpred, vpred}, opts)
566578
end
567579

568580
# @doc ~S"""

lib/norm/conformer.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ defmodule Norm.Conformer do
4747
defp format_val(val) when is_map(val), do: inspect(val)
4848
defp format_val({:index, i}), do: "[#{i}]"
4949
defp format_val(t) when is_tuple(t), do: "#{inspect(t)}"
50+
defp format_val(l) when is_list(l), do: "#{inspect(l)}"
5051
defp format_val(msg), do: "#{msg}"
5152

5253
defprotocol Conformable do

lib/norm/spec/collection.ex

Lines changed: 74 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
defmodule Norm.Spec.Collection do
22
@moduledoc false
33

4-
defstruct spec: nil, opts: [kind: :list]
4+
defstruct spec: nil, opts: []
55

66
def new(spec, opts) do
77
%__MODULE__{spec: spec, opts: opts}
@@ -12,24 +12,85 @@ defmodule Norm.Spec.Collection do
1212
alias Norm.Conformer.Conformable
1313

1414
def conform(%{spec: spec, opts: opts}, input, path) do
15-
results =
16-
input
17-
|> Enum.map(&Conformable.conform(spec, &1, path))
18-
|> Conformer.group_results()
15+
with :ok <- check_distinct(input, path, opts),
16+
:ok <- check_counts(input, path, opts) do
17+
results =
18+
input
19+
|> Enum.with_index()
20+
|> Enum.map(fn {elem, i} -> Conformable.conform(spec, elem, path ++ [i]) end)
21+
|> Conformer.group_results()
1922

20-
if Enum.any?(results.error) do
21-
{:error, results.error}
22-
else
23-
{:ok, convert(results.ok, opts[:kind])}
23+
if Enum.any?(results.error) do
24+
{:error, results.error}
25+
else
26+
{:ok, convert(results.ok, opts[:into])}
27+
end
2428
end
2529
end
2630

27-
defp convert(results, :map) do
28-
Enum.into(results, %{})
31+
defp convert(results, type) do
32+
Enum.into(results, type)
33+
end
34+
35+
defp check_counts(input, path, opts) do
36+
min = opts[:min_count]
37+
max = opts[:max_count]
38+
length = Enum.count(input)
39+
40+
cond do
41+
min > length ->
42+
{:error, [Conformer.error(path, input, "min_count: #{min}")]}
43+
44+
max < length ->
45+
{:error, [Conformer.error(path, input, "max_count: #{max}")]}
46+
47+
true ->
48+
:ok
49+
end
2950
end
3051

31-
defp convert(results, _) do
32-
results
52+
defp check_distinct(input, path, opts) do
53+
if opts[:distinct] do
54+
if Enum.uniq(input) == input do
55+
:ok
56+
else
57+
{:error, [Conformer.error(path, input, "distinct?")]}
58+
end
59+
else
60+
:ok
61+
end
62+
end
63+
end
64+
65+
if Code.ensure_loaded?(StreamData) do
66+
defimpl Norm.Generatable do
67+
def gen(%{spec: spec, opts: opts}) do
68+
with {:ok, g} <- Norm.Generatable.gen(spec) do
69+
generator =
70+
g
71+
|> sequence(opts)
72+
|> into(opts)
73+
74+
{:ok, generator}
75+
end
76+
end
77+
78+
def sequence(g, opts) do
79+
min = opts[:min_count]
80+
max = opts[:max_count]
81+
82+
if opts[:distinct] do
83+
StreamData.uniq_list_of(g, [min_length: min, max_length: max])
84+
else
85+
StreamData.list_of(g, [min_length: min, max_length: max])
86+
end
87+
end
88+
89+
def into(list_gen, opts) do
90+
StreamData.bind(list_gen, fn list ->
91+
StreamData.constant(Enum.into(list, opts[:into]))
92+
end)
93+
end
3394
end
3495
end
3596
end

test/norm_test.exs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,5 +160,99 @@ defmodule NormTest do
160160
spec = map_of(spec(is_integer()), spec(is_atom()))
161161
assert %{1 => :foo, 2 => :bar} == conform!(%{1 => :foo, 2 => :bar}, spec)
162162
end
163+
164+
property "can be generated" do
165+
check all m <- gen(map_of(spec(is_integer()), spec(is_atom()))) do
166+
assert is_map(m)
167+
for k <- Map.keys(m), do: assert is_integer(k)
168+
for v <- Map.values(m), do: assert is_atom(v)
169+
end
170+
end
171+
end
172+
173+
describe "coll_of/2" do
174+
test "can spec collections" do
175+
spec = coll_of(spec(is_atom()))
176+
assert [:foo, :bar, :baz] == conform!([:foo, :bar, :baz], spec)
177+
assert {:error, errors} = conform([:foo, 1, "test"], spec)
178+
assert errors == [
179+
"val: 1 in: 1 fails: is_atom()",
180+
"val: \"test\" in: 2 fails: is_atom()"
181+
]
182+
end
183+
184+
test "conforming returns the conformed values" do
185+
spec = coll_of(schema(%{name: spec(is_binary())}))
186+
input = [
187+
%{name: "chris", age: 31, email: "c@keathley.io"},
188+
%{name: "andra", age: 30}
189+
]
190+
191+
assert [%{name: "chris"}, %{name: "andra"}] == conform!(input, spec)
192+
193+
input = [
194+
%{age: 31, email: "c@keathley.io"},
195+
%{name: :andra, age: 30},
196+
]
197+
assert {:error, errors} = conform(input, spec)
198+
assert errors == [
199+
"val: %{age: 31, email: \"c@keathley.io\"} in: 0/:name fails: :required",
200+
"val: :andra in: 1/:name fails: is_binary()"
201+
]
202+
end
203+
204+
test "can enforce distinct elements" do
205+
spec = coll_of(spec(is_integer()), distinct: true)
206+
207+
assert {:error, errors} = conform([1,1,1], spec)
208+
assert errors == ["val: [1, 1, 1] fails: distinct?"]
209+
end
210+
211+
test "can enforce min and max counts" do
212+
spec = coll_of(spec(is_integer()), min_count: 2, max_count: 3)
213+
assert [1, 1] == conform!([1, 1], spec)
214+
assert [1, 1, 1] == conform!([1, 1, 1], spec)
215+
assert {:error, ["val: [1] fails: min_count: 2"]} == conform([1], spec)
216+
assert {:error, ["val: [1, 1, 1, 1] fails: max_count: 3"]} == conform([1, 1, 1, 1], spec)
217+
218+
spec = coll_of(spec(is_integer()), min_count: 3, max_count: 3)
219+
assert [1, 1, 1] == conform!([1, 1, 1], spec)
220+
end
221+
222+
test "min count must be less than or equal to max count" do
223+
assert_raise ArgumentError, fn ->
224+
coll_of(spec(is_integer()), min_count: 3, max_count: 2)
225+
end
226+
end
227+
228+
test "can be used to spec keyword lists" do
229+
opts = one_of([
230+
{:name, spec(is_atom())},
231+
{:timeout, spec(is_integer())}
232+
])
233+
spec = coll_of(opts)
234+
list = [name: :storage, timeout: 3_000]
235+
236+
assert list == conform!(list, spec)
237+
assert {:error, _errors} = conform([{:foo, :bar} | list], spec)
238+
239+
assert list == conform!(list, coll_of(opts, [min_count: 2, distinct: true]))
240+
assert {:error, errors} = conform([], coll_of(opts, [min_count: 2, distinct: true]))
241+
end
242+
243+
property "can be generated" do
244+
check all is <- gen(coll_of(spec(is_integer()))) do
245+
for i <- is, do: assert is_integer(i)
246+
end
247+
248+
check all is <- gen(coll_of(spec(is_integer()), min_count: 3, max_count: 6)) do
249+
length = Enum.count(is)
250+
assert 3 <= length and length <= 6
251+
end
252+
253+
check all is <- gen(coll_of(spec(is_integer()), distinct: true)) do
254+
assert Enum.uniq(is) == is
255+
end
256+
end
163257
end
164258
end

0 commit comments

Comments
 (0)