Skip to content

Commit 5347763

Browse files
committed
Merge branch 'add-unions'
2 parents ac87648 + 84bda2e commit 5347763

9 files changed

+202
-32
lines changed

README.md

+36-3
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,41 @@ conform!(3, spec(greater?(5)))
8484
(norm) lib/norm.ex:44: Norm.conform!/2
8585
```
8686

87+
### Tuples and atoms
88+
89+
Atoms and tuples can be matched without needing to wrap them in a function.
90+
91+
```elixir
92+
:atom = conform!(:atom, :atom)
93+
94+
{1, "hello"} = conform!({1, "hello"}, {spec(is_integer()), spec(is_binary())})
95+
96+
conform!({1, 2}, {:one, :two})
97+
** (Norm.MismatchError) val: 1 in: 0 fails: is not an atom.
98+
val: 2 in: 1 fails: is not an atom.
99+
```
100+
101+
Because Norm supports matching on bare tuples we can easily validate functions
102+
that return `{:ok, term()}` and `{:error, term()}` tuples.
103+
104+
```elixir
105+
# if User.get_name/1 succeeds it returns {:ok, binary()}
106+
result = User.get_name(123)
107+
{:ok, name} = conform!(result, {:ok, spec(is_binary())})
108+
```
109+
110+
These specifications can be combined with `one_of/1` to create union types.
111+
112+
```elixir
113+
result_spec = one_of([
114+
{:ok, spec(is_binary())},
115+
{:error, spec(fn _ -> true end)},
116+
])
117+
118+
{:ok, "alice"} = conform!(User.get_name(123), result_spec)
119+
{:error, "user does not exist"} = conform!(User.get_name(-42), result_spec)
120+
```
121+
87122
### Schemas
88123

89124
Norm provides a `schema/1` function for specifying maps and structs:
@@ -327,9 +362,7 @@ Norm is being actively worked on. Any contributions are very welcome. Here is a
327362
limited set of ideas that are coming soon.
328363

329364
- [ ] Support generators for other primitive types (floats, etc.)
330-
- [ ] Specify shapes of common elixir primitives (tuples and atoms). This
331-
will allow us to match on the common `{:ok, term()} | {:error, term()}`
332-
pattern in elixir.
365+
- [ ] More streamlined specification of keyword lists.
333366
- [ ] selections shouldn't need a path if you just want to match all the keys in the schema
334367
- [ ] Support "sets" of literal values
335368
- [ ] specs for functions and anonymous functions

lib/norm.ex

+54-4
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,39 @@ defmodule Norm do
6969
(norm) lib/norm.ex:44: Norm.conform!/2
7070
```
7171
72+
### Tuples and atoms
73+
74+
Atoms and tuples can be matched without needing to wrap them in a function.
75+
76+
```elixir
77+
:some_atom = conform!(:some_atom, :atom)
78+
{1, "hello"} = conform!({1, "hello"}, {spec(is_integer()), spec(is_binary())})
79+
conform!({1, 2}, {:one, :two})
80+
** (Norm.MismatchError) val: 1 in: 0 fails: is not an atom.
81+
val: 2 in: 1 fails: is not an atom.
82+
```
83+
84+
Because Norm supports matching on bare tuples we can easily validate functions
85+
that return `{:ok, term()}` and `{:error, term()}` tuples.
86+
87+
```elixir
88+
# if User.get_name/1 succeeds it returns {:ok, binary()}
89+
result = User.get_name(123)
90+
{:ok, name} = conform!(result, {:ok, spec(is_binary())})
91+
```
92+
93+
These specifications can be combined with `one_of/1` to create union types.
94+
95+
```elixir
96+
result_spec = one_of([
97+
{:ok, spec(is_binary())},
98+
{:error, spec(fn _ -> true end)},
99+
])
100+
101+
{:ok, "alice"} = conform!(User.get_name(123), result_spec)
102+
{:error, "user does not exist"} = conform!(User.get_name(-42), result_spec)
103+
```
104+
72105
### Schemas
73106
74107
Norm provides a `schema/1` function for specifying maps and structs:
@@ -185,8 +218,8 @@ defmodule Norm do
185218
186219
conform!(%{type: :delete}, event)
187220
** (Norm.MismatchError)
188-
in: :create/:type val: :delete fails: &(&1 == :create)
189-
in: :update/:type val: :delete fails: &(&1 == :update)
221+
val: :delete in: :create/:type fails: &(&1 == :create)
222+
val: :delete in: :update/:type fails: &(&1 == :update)
190223
```
191224
192225
## Generators
@@ -308,6 +341,7 @@ defmodule Norm do
308341
alias Norm.Spec.{
309342
Alt,
310343
Selection,
344+
Union,
311345
}
312346
alias Norm.Schema
313347
alias Norm.MismatchError
@@ -342,7 +376,8 @@ defmodule Norm do
342376
iex> conform!(42, spec(is_integer()))
343377
42
344378
iex> conform!(42, spec(is_binary()))
345-
** (Norm.MismatchError) val: 42 fails: is_binary()
379+
** (Norm.MismatchError) Could not conform input:
380+
val: 42 fails: is_binary()
346381
"""
347382
def conform!(input, spec) do
348383
case Conformer.conform(spec, input) do
@@ -469,12 +504,27 @@ defmodule Norm do
469504
iex> conform!("foo", alt(num: spec(is_integer()), str: spec(is_binary())))
470505
{:str, "foo"}
471506
iex> conform(true, alt(num: spec(is_integer()), str: spec(is_binary())))
472-
{:error, ["val: true fails: is_integer() in: :num", "val: true fails: is_binary() in: :str"]}
507+
{:error, ["val: true in: :num fails: is_integer()", "val: true in: :str fails: is_binary()"]}
473508
"""
474509
def alt(specs) when is_list(specs) do
475510
%Alt{specs: specs}
476511
end
477512

513+
@doc """
514+
Chooses between a list of options. Unlike `alt/1` the options don't need to
515+
be tagged. Specs are always tested in order and will short circuit if the
516+
data passes a validation.
517+
518+
## Examples
519+
iex> conform!("chris", one_of([spec(is_binary()), :alice]))
520+
"chris"
521+
iex> conform!(:alice, one_of([spec(is_binary()), :alice]))
522+
:alice
523+
"""
524+
def one_of(specs) when is_list(specs) do
525+
Union.new(specs)
526+
end
527+
478528
@doc ~S"""
479529
Selections provide a way to allow optional keys in a schema. This allows
480530
schema's to be defined once and re-used in multiple scenarios.

lib/norm/conformer.ex

+1-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ defmodule Norm.Conformer do
2929
val = "val: #{format_val(input)}"
3030
fails = "fails: #{msg}"
3131

32-
[val, fails, path]
32+
[val, path, fails]
3333
|> Enum.reject(&is_nil/1)
3434
|> Enum.join(" ")
3535
end

lib/norm/errors.ex

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ defmodule Norm.MismatchError do
44
def exception(errors) do
55
msg = Enum.join(errors, "\n")
66

7-
%__MODULE__{message: msg}
7+
%__MODULE__{message: "Could not conform input:\n" <> msg}
88
end
99
end
1010

lib/norm/spec/union.ex

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
defmodule Norm.Spec.Union do
2+
@moduledoc false
3+
# Provides the struct for unions of specifications
4+
5+
defstruct specs: []
6+
7+
def new(specs) do
8+
%__MODULE__{specs: specs}
9+
end
10+
11+
defimpl Norm.Conformer.Conformable do
12+
alias Norm.Conformer
13+
alias Norm.Conformer.Conformable
14+
15+
def conform(%{specs: specs}, input, path) do
16+
result =
17+
specs
18+
|> Enum.map(fn spec -> Conformable.conform(spec, input, path) end)
19+
|> Conformer.group_results
20+
21+
if Enum.any?(result.ok) do
22+
{:ok, Enum.at(result.ok, 0)}
23+
else
24+
{:error, List.flatten(result.error)}
25+
end
26+
end
27+
end
28+
29+
if Code.ensure_loaded?(StreamData) do
30+
defimpl Norm.Generatable do
31+
def gen(%{specs: specs}) do
32+
case Enum.reduce(specs, [], &to_gen/2) do
33+
{:error, error} ->
34+
{:error, error}
35+
36+
generators ->
37+
{:ok, StreamData.one_of(generators)}
38+
end
39+
end
40+
41+
def to_gen(_, {:error, error}), do: {:error, error}
42+
def to_gen(spec, generators) do
43+
case Norm.Generatable.gen(spec) do
44+
{:ok, g} ->
45+
[g | generators]
46+
47+
{:error, error} ->
48+
{:error, error}
49+
end
50+
end
51+
end
52+
end
53+
end

test/norm/schema_test.exs

+13-13
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,11 @@ defmodule Norm.SchemaTest do
4141

4242
assert %{name: "chris", age: 31} == conform!(%{name: "chris", age: 31}, s)
4343
assert {:error, errors} = conform(%{name: "chris"}, s)
44-
assert errors == ["val: %{name: \"chris\"} fails: :required in: :age"]
44+
assert errors == ["val: %{name: \"chris\"} in: :age fails: :required"]
4545

4646
user = schema(%{user: s})
4747
assert {:error, errors} = conform(%{user: %{age: 31}}, user)
48-
assert errors == ["val: %{age: 31} fails: :required in: :user/:name"]
48+
assert errors == ["val: %{age: 31} in: :user/:name fails: :required"]
4949
end
5050

5151
test "works with boolean values" do
@@ -95,8 +95,8 @@ defmodule Norm.SchemaTest do
9595
assert {:other, other} == conform!(other, user_or_other)
9696
assert {:error, errors} = conform(%{}, user_or_other)
9797
assert errors == [
98-
"val: %{} fails: Norm.SchemaTest.User in: :user",
99-
"val: %{} fails: Norm.SchemaTest.OtherUser in: :other"
98+
"val: %{} in: :user fails: Norm.SchemaTest.User",
99+
"val: %{} in: :other fails: Norm.SchemaTest.OtherUser"
100100
]
101101
end
102102

@@ -108,8 +108,8 @@ defmodule Norm.SchemaTest do
108108
assert %{a: {:int, 123}} == conform!(%{a: 123}, s)
109109
assert {:error, errors} = conform(%{a: "test"}, s)
110110
assert errors == [
111-
"val: \"test\" fails: is_boolean() in: :a/:bool",
112-
"val: \"test\" fails: is_integer() in: :a/:int"
111+
"val: \"test\" in: :a/:bool fails: is_boolean()",
112+
"val: \"test\" in: :a/:int fails: is_integer()"
113113
]
114114
end
115115

@@ -119,7 +119,7 @@ defmodule Norm.SchemaTest do
119119
})
120120

121121
assert {:error, errors} = conform(%{name: "chris", age: 31}, user_schema)
122-
assert errors == ["val: %{age: 31, name: \"chris\"} fails: :unexpected in: :age"]
122+
assert errors == ["val: %{age: 31, name: \"chris\"} in: :age fails: :unexpected"]
123123
end
124124

125125
test "works with string keys and atom keys" do
@@ -136,8 +136,8 @@ defmodule Norm.SchemaTest do
136136
assert input == conform!(input, user)
137137
assert {:error, errors} = conform(%{"name" => 31, age: "chris"}, user)
138138
assert errors == [
139-
"val: \"chris\" fails: is_integer() in: :age",
140-
"val: 31 fails: is_binary() in: \"name\""
139+
"val: \"chris\" in: :age fails: is_integer()",
140+
"val: 31 in: \"name\" fails: is_binary()"
141141
]
142142
end
143143

@@ -167,9 +167,9 @@ defmodule Norm.SchemaTest do
167167

168168
assert input == conform!(input, User.s())
169169
assert {:error, errors} = conform(%User{name: :foo, age: "31", email: 42}, User.s())
170-
assert errors == ["val: \"31\" fails: is_integer() in: :age",
171-
"val: 42 fails: is_binary() in: :email",
172-
"val: :foo fails: is_binary() in: :name"]
170+
assert errors == ["val: \"31\" in: :age fails: is_integer()",
171+
"val: 42 in: :email fails: is_binary()",
172+
"val: :foo in: :name fails: is_binary()"]
173173
end
174174

175175
test "only checks the keys that have specs" do
@@ -178,7 +178,7 @@ defmodule Norm.SchemaTest do
178178

179179
assert input == conform!(input, spec)
180180
assert {:error, errors} = conform(%User{name: 23}, spec)
181-
assert errors == ["val: 23 fails: is_binary() in: :name"]
181+
assert errors == ["val: 23 in: :name fails: is_binary()"]
182182
end
183183

184184
property "can generate proper structs" do

test/norm/selection_test.exs

+4-4
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ defmodule Norm.SelectionTest do
1919
assert %{age: 31} == conform!(@input, selection(user_schema(), [:age]))
2020
assert %{age: 31, name: "chris"} == conform!(@input, selection(user_schema(), [:age, :name]))
2121
assert {:error, errors} = conform(%{age: -100}, selection(user_schema(), [:age]))
22-
assert errors == ["val: -100 fails: &(&1 > 0) in: :age"]
22+
assert errors == ["val: -100 in: :age fails: &(&1 > 0)"]
2323
end
2424

2525
test "works with nested schemas" do
@@ -28,11 +28,11 @@ defmodule Norm.SelectionTest do
2828

2929
assert %{user: %{age: 31}} == conform!(%{user: %{age: 31}}, selection)
3030
assert {:error, errors} = conform(%{user: %{age: -100}}, selection)
31-
assert errors == ["val: -100 fails: &(&1 > 0) in: :user/:age"]
31+
assert errors == ["val: -100 in: :user/:age fails: &(&1 > 0)"]
3232
assert {:error, errors} = conform(%{user: %{name: "chris"}}, selection)
33-
assert errors == ["val: %{name: \"chris\"} fails: :required in: :user/:age"]
33+
assert errors == ["val: %{name: \"chris\"} in: :user/:age fails: :required"]
3434
assert {:error, errors} = conform(%{fauxuser: %{age: 31}}, selection)
35-
assert errors == ["val: %{fauxuser: %{age: 31}} fails: :required in: :user"]
35+
assert errors == ["val: %{fauxuser: %{age: 31}} in: :user fails: :required"]
3636
end
3737

3838
test "errors if there are keys that aren't specified in a schema" do

test/norm/union_test.exs

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
defmodule Norm.UnionTest do
2+
use ExUnit.Case, async: true
3+
import ExUnitProperties, except: [gen: 1]
4+
import Norm
5+
6+
describe "conforming" do
7+
test "returns the first match" do
8+
union = one_of([:foo, spec(is_binary())])
9+
10+
assert :foo == conform!(:foo, union)
11+
assert "chris" == conform!("chris", union)
12+
assert {:error, errors} = conform(123, union)
13+
assert errors == [
14+
"val: 123 fails: is not an atom.",
15+
"val: 123 fails: is_binary()"
16+
]
17+
end
18+
end
19+
20+
describe "generation" do
21+
property "randomly selects one of the options" do
22+
union = one_of([:foo, spec(is_binary())])
23+
24+
check all e <- gen(union) do
25+
assert e == :foo || is_binary(e)
26+
end
27+
end
28+
end
29+
end

test/norm_test.exs

+11-6
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,12 @@ defmodule NormTest do
2929
assert {1, 2, 3} == conform!({1, 2, 3}, three)
3030
assert {:error, errors} = conform({1, :bar, "foo"}, three)
3131
assert errors == [
32-
"val: :bar fails: is_integer() in: 1",
33-
"val: \"foo\" fails: is_integer() in: 2"
32+
"val: :bar in: 1 fails: is_integer()",
33+
"val: \"foo\" in: 2 fails: is_integer()"
3434
]
3535

3636
assert {:error, errors} = conform({:ok, "foo"}, ok)
37-
assert errors == ["val: \"foo\" fails: is_integer() in: 1"]
37+
assert errors == ["val: \"foo\" in: 1 fails: is_integer()"]
3838

3939
assert {:error, errors} = conform({:ok, "foo", 123}, ok)
4040
assert errors == ["val: {:ok, \"foo\", 123} fails: incorrect tuple size"]
@@ -49,7 +49,12 @@ defmodule NormTest do
4949

5050
assert {:ok, %{name: "chris"}} == conform!({:ok, %{name: "chris", age: 31}}, ok)
5151
assert {:error, errors} = conform({:ok, %{age: 31}}, ok)
52-
assert errors == ["val: %{age: 31} fails: :required in: 1/:name"]
52+
assert errors == ["val: %{age: 31} in: 1/:name fails: :required"]
53+
end
54+
55+
@tag :skip
56+
test "can spec keyword lists" do
57+
flunk "Not Implemented"
5358
end
5459
end
5560

@@ -127,8 +132,8 @@ defmodule NormTest do
127132
assert {:b, "foo"} == conform!("foo", spec)
128133
assert {:error, errors} = conform(%{name: :alice}, spec)
129134
assert errors == [
130-
"val: :alice fails: is_binary() in: :a/:name",
131-
"val: %{name: :alice} fails: is_binary() in: :b"
135+
"val: :alice in: :a/:name fails: is_binary()",
136+
"val: %{name: :alice} in: :b fails: is_binary()"
132137
]
133138
end
134139

0 commit comments

Comments
 (0)