Repository: keathley/norm
Pull request #5 (2019-08-22 02:47:11)

Add unions


Patch:
diff --git a/README.md b/README.md
index c5a9699..faeb971 100644
--- a/README.md
+++ b/README.md
@@ -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:
@@ -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
diff --git a/lib/norm.ex b/lib/norm.ex
index 2c63717..94e87bb 100644
--- a/lib/norm.ex
+++ b/lib/norm.ex
@@ -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:
@@ -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
@@ -308,6 +341,7 @@ defmodule Norm do
   alias Norm.Spec.{
     Alt,
     Selection,
+    Union,
   }
   alias Norm.Schema
   alias Norm.MismatchError
@@ -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
@@ -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.
diff --git a/lib/norm/conformer.ex b/lib/norm/conformer.ex
index 41f1937..5fe96d8 100644
--- a/lib/norm/conformer.ex
+++ b/lib/norm/conformer.ex
@@ -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
diff --git a/lib/norm/errors.ex b/lib/norm/errors.ex
index e92af59..1d53c5c 100644
--- a/lib/norm/errors.ex
+++ b/lib/norm/errors.ex
@@ -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
 
diff --git a/lib/norm/spec/union.ex b/lib/norm/spec/union.ex
new file mode 100644
index 0000000..8aff848
--- /dev/null
+++ b/lib/norm/spec/union.ex
@@ -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


Test patch:
diff --git a/test/norm/schema_test.exs b/test/norm/schema_test.exs
index 28c6956..d9ed06b 100644
--- a/test/norm/schema_test.exs
+++ b/test/norm/schema_test.exs
@@ -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
@@ -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
 
@@ -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
 
@@ -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
@@ -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
 
@@ -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
@@ -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
diff --git a/test/norm/selection_test.exs b/test/norm/selection_test.exs
index 2fc03c3..43d53f4 100644
--- a/test/norm/selection_test.exs
+++ b/test/norm/selection_test.exs
@@ -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
@@ -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
diff --git a/test/norm/union_test.exs b/test/norm/union_test.exs
new file mode 100644
index 0000000..3621393
--- /dev/null
+++ b/test/norm/union_test.exs
@@ -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
diff --git a/test/norm_test.exs b/test/norm_test.exs
index 5e01c86..a4860c3 100644
--- a/test/norm_test.exs
+++ b/test/norm_test.exs
@@ -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"]
@@ -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
 
@@ -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