Skip to content

Commit

Permalink
Merge branch 'add-unions'
Browse files Browse the repository at this point in the history
  • Loading branch information
keathley committed Aug 22, 2019
2 parents ac87648 + 84bda2e commit 5347763
Show file tree
Hide file tree
Showing 9 changed files with 202 additions and 32 deletions.
39 changes: 36 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,41 @@ conform!(3, spec(greater?(5)))
(norm) lib/norm.ex:44: Norm.conform!/2
```

### Tuples and atoms

Atoms and tuples can be matched without needing to wrap them in a function.

```elixir
:atom = conform!(:atom, :atom)

{1, "hello"} = conform!({1, "hello"}, {spec(is_integer()), spec(is_binary())})

conform!({1, 2}, {:one, :two})
** (Norm.MismatchError) val: 1 in: 0 fails: is not an atom.
val: 2 in: 1 fails: is not an atom.
```

Because Norm supports matching on bare tuples we can easily validate functions
that return `{:ok, term()}` and `{:error, term()}` tuples.

```elixir
# if User.get_name/1 succeeds it returns {:ok, binary()}
result = User.get_name(123)
{:ok, name} = conform!(result, {:ok, spec(is_binary())})
```

These specifications can be combined with `one_of/1` to create union types.

```elixir
result_spec = one_of([
{:ok, spec(is_binary())},
{:error, spec(fn _ -> true end)},
])

{:ok, "alice"} = conform!(User.get_name(123), result_spec)
{:error, "user does not exist"} = conform!(User.get_name(-42), result_spec)
```

### Schemas

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

- [ ] Support generators for other primitive types (floats, etc.)
- [ ] Specify shapes of common elixir primitives (tuples and atoms). This
will allow us to match on the common `{:ok, term()} | {:error, term()}`
pattern in elixir.
- [ ] More streamlined specification of keyword lists.
- [ ] selections shouldn't need a path if you just want to match all the keys in the schema
- [ ] Support "sets" of literal values
- [ ] specs for functions and anonymous functions
Expand Down
58 changes: 54 additions & 4 deletions lib/norm.ex
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,39 @@ defmodule Norm do
(norm) lib/norm.ex:44: Norm.conform!/2
```
### Tuples and atoms
Atoms and tuples can be matched without needing to wrap them in a function.
```elixir
:some_atom = conform!(:some_atom, :atom)
{1, "hello"} = conform!({1, "hello"}, {spec(is_integer()), spec(is_binary())})
conform!({1, 2}, {:one, :two})
** (Norm.MismatchError) val: 1 in: 0 fails: is not an atom.
val: 2 in: 1 fails: is not an atom.
```
Because Norm supports matching on bare tuples we can easily validate functions
that return `{:ok, term()}` and `{:error, term()}` tuples.
```elixir
# if User.get_name/1 succeeds it returns {:ok, binary()}
result = User.get_name(123)
{:ok, name} = conform!(result, {:ok, spec(is_binary())})
```
These specifications can be combined with `one_of/1` to create union types.
```elixir
result_spec = one_of([
{:ok, spec(is_binary())},
{:error, spec(fn _ -> true end)},
])
{:ok, "alice"} = conform!(User.get_name(123), result_spec)
{:error, "user does not exist"} = conform!(User.get_name(-42), result_spec)
```
### Schemas
Norm provides a `schema/1` function for specifying maps and structs:
Expand Down Expand Up @@ -185,8 +218,8 @@ defmodule Norm do
conform!(%{type: :delete}, event)
** (Norm.MismatchError)
in: :create/:type val: :delete fails: &(&1 == :create)
in: :update/:type val: :delete fails: &(&1 == :update)
val: :delete in: :create/:type fails: &(&1 == :create)
val: :delete in: :update/:type fails: &(&1 == :update)
```
## Generators
Expand Down Expand Up @@ -308,6 +341,7 @@ defmodule Norm do
alias Norm.Spec.{
Alt,
Selection,
Union,
}
alias Norm.Schema
alias Norm.MismatchError
Expand Down Expand Up @@ -342,7 +376,8 @@ defmodule Norm do
iex> conform!(42, spec(is_integer()))
42
iex> conform!(42, spec(is_binary()))
** (Norm.MismatchError) val: 42 fails: is_binary()
** (Norm.MismatchError) Could not conform input:
val: 42 fails: is_binary()
"""
def conform!(input, spec) do
case Conformer.conform(spec, input) do
Expand Down Expand Up @@ -469,12 +504,27 @@ defmodule Norm do
iex> conform!("foo", alt(num: spec(is_integer()), str: spec(is_binary())))
{:str, "foo"}
iex> conform(true, alt(num: spec(is_integer()), str: spec(is_binary())))
{:error, ["val: true fails: is_integer() in: :num", "val: true fails: is_binary() in: :str"]}
{:error, ["val: true in: :num fails: is_integer()", "val: true in: :str fails: is_binary()"]}
"""
def alt(specs) when is_list(specs) do
%Alt{specs: specs}
end

@doc """
Chooses between a list of options. Unlike `alt/1` the options don't need to
be tagged. Specs are always tested in order and will short circuit if the
data passes a validation.
## Examples
iex> conform!("chris", one_of([spec(is_binary()), :alice]))
"chris"
iex> conform!(:alice, one_of([spec(is_binary()), :alice]))
:alice
"""
def one_of(specs) when is_list(specs) do
Union.new(specs)
end

@doc ~S"""
Selections provide a way to allow optional keys in a schema. This allows
schema's to be defined once and re-used in multiple scenarios.
Expand Down
2 changes: 1 addition & 1 deletion lib/norm/conformer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ defmodule Norm.Conformer do
val = "val: #{format_val(input)}"
fails = "fails: #{msg}"

[val, fails, path]
[val, path, fails]
|> Enum.reject(&is_nil/1)
|> Enum.join(" ")
end
Expand Down
2 changes: 1 addition & 1 deletion lib/norm/errors.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule Norm.MismatchError do
def exception(errors) do
msg = Enum.join(errors, "\n")

%__MODULE__{message: msg}
%__MODULE__{message: "Could not conform input:\n" <> msg}
end
end

Expand Down
53 changes: 53 additions & 0 deletions lib/norm/spec/union.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
defmodule Norm.Spec.Union do
@moduledoc false
# Provides the struct for unions of specifications

defstruct specs: []

def new(specs) do
%__MODULE__{specs: specs}
end

defimpl Norm.Conformer.Conformable do
alias Norm.Conformer
alias Norm.Conformer.Conformable

def conform(%{specs: specs}, input, path) do
result =
specs
|> Enum.map(fn spec -> Conformable.conform(spec, input, path) end)
|> Conformer.group_results

if Enum.any?(result.ok) do
{:ok, Enum.at(result.ok, 0)}
else
{:error, List.flatten(result.error)}
end
end
end

if Code.ensure_loaded?(StreamData) do
defimpl Norm.Generatable do
def gen(%{specs: specs}) do
case Enum.reduce(specs, [], &to_gen/2) do
{:error, error} ->
{:error, error}

generators ->
{:ok, StreamData.one_of(generators)}
end
end

def to_gen(_, {:error, error}), do: {:error, error}
def to_gen(spec, generators) do
case Norm.Generatable.gen(spec) do
{:ok, g} ->
[g | generators]

{:error, error} ->
{:error, error}
end
end
end
end
end
26 changes: 13 additions & 13 deletions test/norm/schema_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,11 @@ defmodule Norm.SchemaTest do

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

user = schema(%{user: s})
assert {:error, errors} = conform(%{user: %{age: 31}}, user)
assert errors == ["val: %{age: 31} fails: :required in: :user/:name"]
assert errors == ["val: %{age: 31} in: :user/:name fails: :required"]
end

test "works with boolean values" do
Expand Down Expand Up @@ -95,8 +95,8 @@ defmodule Norm.SchemaTest do
assert {:other, other} == conform!(other, user_or_other)
assert {:error, errors} = conform(%{}, user_or_other)
assert errors == [
"val: %{} fails: Norm.SchemaTest.User in: :user",
"val: %{} fails: Norm.SchemaTest.OtherUser in: :other"
"val: %{} in: :user fails: Norm.SchemaTest.User",
"val: %{} in: :other fails: Norm.SchemaTest.OtherUser"
]
end

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

Expand All @@ -119,7 +119,7 @@ defmodule Norm.SchemaTest do
})

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

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

Expand Down Expand Up @@ -167,9 +167,9 @@ defmodule Norm.SchemaTest do

assert input == conform!(input, User.s())
assert {:error, errors} = conform(%User{name: :foo, age: "31", email: 42}, User.s())
assert errors == ["val: \"31\" fails: is_integer() in: :age",
"val: 42 fails: is_binary() in: :email",
"val: :foo fails: is_binary() in: :name"]
assert errors == ["val: \"31\" in: :age fails: is_integer()",
"val: 42 in: :email fails: is_binary()",
"val: :foo in: :name fails: is_binary()"]
end

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

assert input == conform!(input, spec)
assert {:error, errors} = conform(%User{name: 23}, spec)
assert errors == ["val: 23 fails: is_binary() in: :name"]
assert errors == ["val: 23 in: :name fails: is_binary()"]
end

property "can generate proper structs" do
Expand Down
8 changes: 4 additions & 4 deletions test/norm/selection_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ defmodule Norm.SelectionTest do
assert %{age: 31} == conform!(@input, selection(user_schema(), [:age]))
assert %{age: 31, name: "chris"} == conform!(@input, selection(user_schema(), [:age, :name]))
assert {:error, errors} = conform(%{age: -100}, selection(user_schema(), [:age]))
assert errors == ["val: -100 fails: &(&1 > 0) in: :age"]
assert errors == ["val: -100 in: :age fails: &(&1 > 0)"]
end

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

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

test "errors if there are keys that aren't specified in a schema" do
Expand Down
29 changes: 29 additions & 0 deletions test/norm/union_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
defmodule Norm.UnionTest do
use ExUnit.Case, async: true
import ExUnitProperties, except: [gen: 1]
import Norm

describe "conforming" do
test "returns the first match" do
union = one_of([:foo, spec(is_binary())])

assert :foo == conform!(:foo, union)
assert "chris" == conform!("chris", union)
assert {:error, errors} = conform(123, union)
assert errors == [
"val: 123 fails: is not an atom.",
"val: 123 fails: is_binary()"
]
end
end

describe "generation" do
property "randomly selects one of the options" do
union = one_of([:foo, spec(is_binary())])

check all e <- gen(union) do
assert e == :foo || is_binary(e)
end
end
end
end
17 changes: 11 additions & 6 deletions test/norm_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,12 @@ defmodule NormTest do
assert {1, 2, 3} == conform!({1, 2, 3}, three)
assert {:error, errors} = conform({1, :bar, "foo"}, three)
assert errors == [
"val: :bar fails: is_integer() in: 1",
"val: \"foo\" fails: is_integer() in: 2"
"val: :bar in: 1 fails: is_integer()",
"val: \"foo\" in: 2 fails: is_integer()"
]

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

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

assert {:ok, %{name: "chris"}} == conform!({:ok, %{name: "chris", age: 31}}, ok)
assert {:error, errors} = conform({:ok, %{age: 31}}, ok)
assert errors == ["val: %{age: 31} fails: :required in: 1/:name"]
assert errors == ["val: %{age: 31} in: 1/:name fails: :required"]
end

@tag :skip
test "can spec keyword lists" do
flunk "Not Implemented"
end
end

Expand Down Expand Up @@ -127,8 +132,8 @@ defmodule NormTest do
assert {:b, "foo"} == conform!("foo", spec)
assert {:error, errors} = conform(%{name: :alice}, spec)
assert errors == [
"val: :alice fails: is_binary() in: :a/:name",
"val: %{name: :alice} fails: is_binary() in: :b"
"val: :alice in: :a/:name fails: is_binary()",
"val: %{name: :alice} in: :b fails: is_binary()"
]
end

Expand Down

0 comments on commit 5347763

Please sign in to comment.