Repository: keathley/norm
Pull request #26 (2019-12-01 21:58:07)

Optionality in schemas
This PR introduces a fairly substantial change to the semantics of schema's and selections. I'll try to describe the behavior before and after these changes as well as my rationale.

## The current state

Currently, schemas disregard any non-specified keys in the input map and return only what's been specified. Selections further filter this input set in order to return a limited set of keys. This code should demonstrate the idea:

```elixir
user = schema(%{name: spec(is_binary())})
%{name: "chris"} = conform!(%{name: "chris", age: 31}, user)
```

Furthermore, all of the schema's keys are required in the input map.

## The problems

By removing unspecified keys from the input we make it impossible to support dynamic user input or arbitrary middlewares. This example should demonstrate what I mean:

```elixir
def env_s, do: schema(%{content_type: spec(is_binary()), method: spec(& &1 in ["POST", "GET"]})

def user_middleware(env) do
  # The env should contain the specified values
  conform!(env, env_s())
  Map.put(env, :user_id, "User ID")
  env
end

def run(middleware \\ []) do
  # Magically get the env. We could pull the values from a plug conn or some other location.
  env = build_env()
  env = Enum.reduce(middleware, env, fn m, prev_env -> m.(prev_env) end)

  # We still want to ensure that our data is there but we don't want to discard the users data.
  env = conform!(env, env_s())
end

run([&user_middleware/1])
```

In our example the user's function can add arbitrary fields to the `env` map. Once the middleware has run we want to ensure that we haven't lost our specified fields, but we don't want to remove the other fields the user has added. The current design limits our ability to handle this case.

The other problem is around optionality. There's not a good way to say that a key is optional in a given context. Selections don't help us here since they only limit the conformed output. In practice this means that users end up having to specify a number of different schemas for different use cases instead of being able to re-use existing schemas.

## Solutions

I've thought about these problems for a while and I think I've arrived at a good path forward. After this PR schema and selection will return the entire input payload so long as any specified keys conform correctly. This makes Norm much more open to growth and supports the specific problems I described above.

In order to support optional keys, all the keys in a schema will now be optional. That means that all of these scenarios would be considered valid:

```elixir
user_schema = schema(%{
  name: spec(is_binary()),
  age: spec(is_integer()),
  email: spec(is_binary())
})
iex> valid?(%{}, user_schema)
true
iex> valid?(%{name: "chris"}, user_schema)
true
iex> valid?(%{name: "chris", age: 31}, user_schema)
true
iex> valid?(%{age: 31, other_key: "other"}, user_schema)
true
```

In order to mark certain fields as required we can use `selection`. Selection's will ensure that at least the keys specified are present.

```elixir
user_schema = schema(%{
  name: spec(is_binary()),
  age: spec(is_integer()),
  email: spec(is_binary())
})
iex> valid?(%{}, selection(user_schema, [:name])
false
iex> valid?(%{name: "chris"}, selection(user_schema, [:name]))
true
iex> valid?(%{name: "chris", age: 31}, selection(user_schema, [:name]))
true
```

If you want to mark all fields in all schema's as required, recursively, I've also provided a shorthand. Instead of specifying lists of keys you can use the single arity version of `selection` like so:

```elixir
schema = schema(%{
  a: spec(is_binary()),
  b: spec(is_binary()),
  c: spec(is_binary())
})
iex> valid?(%{}, selection(schema))
false
iex> valid?(%{a: "a", b: "b", c: "c"}, selection(schema))
true
```

Since selections no longer limit the returned values this effectively solves the optionality problem I described above. These semantics should allow us to support many more use cases and should increase the usability of schema's and selections.

## Future work

After these changes I'm beginning to think that the name `selection` no longer makes sense. Maybe something like `required` would be more meaningful. I'm going to think about that a bit more.


Patch:
diff --git a/README.md b/README.md
index eb10cce..f202c10 100644
--- a/README.md
+++ b/README.md
@@ -142,6 +142,30 @@ conform!(%{user: %{name: "chris", age: -31}}, user_schema)
     (norm) lib/norm.ex:44: Norm.conform!/2
 ```
 
+Schema's are designed to allow systems to grow over time. They provide this
+functionality in two ways. The first is that any unspecified fields in the input
+are passed through when conforming the input. The second is that all keys in a
+schema are optional. This means that all of these are valid:
+
+```elixir
+user_schema = schema(%{
+  name: spec(is_binary()),
+  age: spec(is_integer()),
+})
+
+conform!(%{}, user_schema)
+=> %{}
+conform!(%{age: 31}, user_schema)
+=> %{age: 31}
+conform!(%{foo: :foo, bar: :bar}, user_schema)
+=> %{foo: :foo, bar: :bar}
+```
+
+If you're used to more restrictive systems for managing data these might seem
+like odd choices. We'll see how to specify required keys when we discuss Selections.
+
+#### Structs
+
 You can also create specs from structs:
 
 ```elixir
@@ -183,23 +207,12 @@ Schemas accomodate growth by disregarding any unspecified keys in the input map.
 This allows callers to start sending new data over time without coordination
 with the consuming function.
 
-### Selections
+### Selections and optionality
 
-You may have noticed that there's no way to specify optional keys in
-a schema. This may seem like an oversight but its actually an intentional
-design decision. Whether a key should be present in a schema is determined
-by the call site and not by the schema itself. For instance think about
-the assigns in a plug conn. When are the assigns optional? It depends on
-where you are in the pipeline.
-
-Schemas also force all keys to match at all times. This is generally
-useful as it limits your ability to introduce errors. But it also limits
-schema growth and turns changes that should be non-breaking into breaking
-changes.
-
-In order to support both of these scenarios Norm provides the
-`selection/2` function. `selection/2` allows you to specify exactly the
-keys you require from a schema at the place where you require them.
+We said that all of the fields in a schema are optional. In order to specify
+the keys that are required in a specific use case we can use a Selection. The
+Selections takes a schema and a list of keys - or keys to lists of keys - that
+must be present in the schema.
 
 ```elixir
 user_schema = schema(%{
@@ -211,13 +224,33 @@ user_schema = schema(%{
 just_age = selection(user_schema, [user: [:age]])
 
 conform!(%{user: %{name: "chris", age: 31}}, just_age)
-=> %{user: %{age: 31}}
+=> %{user: %{age: 31, name: "chris"}}
 
-# Selection also disregards unspecified keys
-conform!(%{user: %{name: "chris", age: 31, unspecified: nil}, other_stuff: :foo}, just_age)
-=> %{user: %{age: 31}}
+conform!(%{user: %{name: "chris"}}, just_age)
+** (Norm.MismatchError) Could not conform input:
+val: %{name: "chris"} in: :user/:age fails: :required
+    (norm) lib/norm.ex:387: Norm.conform!/2
 ```
 
+If you need to mark all fields in a schema as required you can elide the list
+of keys like so:
+
+```elixir
+user_schema = schema(%{
+  user: schema(%{
+    name: spec(is_binary()),
+    age: spec(is_integer()),
+  })
+})
+
+# Require all fields recursively
+conform!(%{user: %{name: "chris", age: 31}}, selection(user_schema))
+```
+
+Selections are an important tool because they give control over optionality
+back to the call site. This allows callers to determine what they actually need
+and makes schema's much more reusable.
+
 ### Patterns
 
 Norm provides a way to specify alternative specs using the `alt/1`
@@ -363,9 +396,7 @@ working to make improvements.
 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.)
 - [ ] 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
 - [ ] easier way to do dispatch based on schema keys
diff --git a/lib/norm.ex b/lib/norm.ex
index 4eb9670..61fd307 100644
--- a/lib/norm.ex
+++ b/lib/norm.ex
@@ -478,19 +478,64 @@ defmodule Norm do
   end
 
   @doc ~S"""
-  Creates a re-usable schema.
+  Creates a re-usable schema. Schema's are open which means that all keys are
+  optional and any non-specified keys are passed through without being conformed.
+  If you need to mark keys as required instead of optional you can use `selection`.
 
   ## Examples
 
-      iex> conform!(%{age: 31, name: "chris"},
-      ...>   schema(%{age: spec(is_integer()), name: spec(is_binary())})
-      ...> )
+      iex> valid?(%{}, schema(%{name: spec(is_binary())}))
+      true
+      iex> valid?(%{name: "Chris"}, schema(%{name: spec(is_binary())}))
+      true
+      iex> valid?(%{name: "Chris", age: 31}, schema(%{name: spec(is_binary())}))
+      true
+      iex> valid?(%{age: 31}, schema(%{name: spec(is_binary())}))
+      true
+      iex> valid?(%{name: 123}, schema(%{name: spec(is_binary())}))
+      false
+      iex> conform!(%{}, schema(%{name: spec(is_binary())}))
+      %{}
+      iex> conform!(%{age: 31, name: "chris"}, schema(%{name: spec(is_binary())}))
       %{age: 31, name: "chris"}
+      iex> conform!(%{age: 31}, schema(%{name: spec(is_binary())}))
+      %{age: 31}
+      iex> conform!(%{user: %{name: "chris"}}, schema(%{user: schema(%{name: spec(is_binary())})}))
+      %{user: %{name: "chris"}}
   """
   def schema(input) when is_map(input) do
     Schema.build(input)
   end
 
+  @doc ~S"""
+  Selections can be used to mark keys on a schema as required. Any unspecified keys
+  in the selection are still considered optional. Selections, like schemas,
+  are open and allow unspecied keys to be passed through. If no selectors are
+  provided then `selection` defaults to `:all` and recursively marks all keys in
+  all nested schema's. If the schema includes internal selections these selections
+  will not be overwritten.
+
+  ## Examples
+
+      iex> valid?(%{name: "chris"}, selection(schema(%{name: spec(is_binary())}), [:name]))
+      true
+      iex> valid?(%{}, selection(schema(%{name: spec(is_binary())}), [:name]))
+      false
+      iex> valid?(%{user: %{name: "chris"}}, selection(schema(%{user: schema(%{name: spec(is_binary())})}), [user: [:name]]))
+      true
+      iex> conform!(%{name: "chris"}, selection(schema(%{name: spec(is_binary())}), [:name]))
+      %{name: "chris"}
+      iex> conform!(%{name: "chris", age: 31}, selection(schema(%{name: spec(is_binary())}), [:name]))
+      %{name: "chris", age: 31}
+
+  ## Require all keys
+      iex> valid?(%{user: %{name: "chris"}}, selection(schema(%{user: schema(%{name: spec(is_binary())})})))
+      true
+  """
+  def selection(%Schema{} = schema, path \\ :all) do
+    Selection.new(schema, path)
+  end
+
   @doc ~S"""
   Chooses between alternative predicates or patterns. The patterns must be tagged with an atom.
   When conforming data to this specification the data is returned as a tuple with the tag.
@@ -527,19 +572,6 @@ defmodule Norm 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.
-
-  ## Examples
-
-      iex> conform!(%{age: 31}, selection(schema(%{age: spec(is_integer()), name: spec(is_binary())}), [:age]))
-      %{age: 31}
-  """
-  def selection(%Schema{} = schema, path) do
-    Selection.new(schema, path)
-  end
-
   @doc ~S"""
   Specifies a generic collection. Collections can be any enumerable type.
 
diff --git a/lib/norm/schema.ex b/lib/norm/schema.ex
index 48f0afd..ff45e22 100644
--- a/lib/norm/schema.ex
+++ b/lib/norm/schema.ex
@@ -4,7 +4,7 @@ defmodule Norm.Schema do
 
   alias __MODULE__
 
-  defstruct specs: [], struct: nil
+  defstruct specs: %{}, struct: nil
 
   # If we're building a schema from a struct then we need to add a default spec
   # for each key that only checks for presence. This allows users to specify
@@ -13,17 +13,12 @@ defmodule Norm.Schema do
     specs =
       struct
       |> Map.from_struct()
-      |> Enum.to_list()
 
     %Schema{specs: specs, struct: name}
   end
 
   def build(map) when is_map(map) do
-    specs =
-      map
-      |> Enum.to_list()
-
-    %Schema{specs: specs}
+    %Schema{specs: map}
   end
 
   def spec(schema, key) do
@@ -41,30 +36,34 @@ defmodule Norm.Schema do
       {:error, [Conformer.error(path, input, "not a map")]}
     end
 
+    # Conforming a struct
     def conform(%{specs: specs, struct: target}, input, path) when not is_nil(target) do
       # Ensure we're mapping the correct struct
-      if Map.get(input, :__struct__) == target do
-        with {:ok, conformed} <- check_specs(specs, input, path) do
-          {:ok, struct(target, conformed)}
-        end
-      else
-        short_name =
-          target
-          |> Atom.to_string()
-          |> String.replace("Elixir.", "")
+      cond do
+        Map.get(input, :__struct__) != target ->
+          short_name =
+            target
+            |> Atom.to_string()
+            |> String.replace("Elixir.", "")
+
+          {:error, [Conformer.error(path, input, "#{short_name}")]}
 
-        {:error, [Conformer.error(path, input, "#{short_name}")]}
+        true ->
+          with {:ok, conformed} <- check_specs(specs, Map.from_struct(input), path) do
+            {:ok, struct(target, conformed)}
+          end
       end
     end
 
+    # conforming a map.
     def conform(%Norm.Schema{specs: specs}, input, path) do
       check_specs(specs, input, path)
     end
 
     defp check_specs(specs, input, path) do
       results =
-        specs
-        |> Enum.map(&check_spec(&1, input, path))
+        input
+        |> Enum.map(&check_spec(&1, specs, path))
         |> Enum.reduce(%{ok: [], error: []}, fn {key, {result, conformed}}, acc ->
           Map.put(acc, result, acc[result] ++ [{key, conformed}])
         end)
@@ -80,24 +79,13 @@ defmodule Norm.Schema do
       end
     end
 
-    defp check_spec({key, nil}, input, path) do
-      case Map.has_key?(input, key) do
-        false ->
-          {key, {:error, [Conformer.error(path ++ [key], input, ":required")]}}
+    defp check_spec({key, value}, specs, path) do
+      case Map.get(specs, key) do
+        nil ->
+          {key, {:ok, value}}
 
-        true ->
-          {key, {:ok, Map.get(input, key)}}
-      end
-    end
-
-    defp check_spec({key, spec}, input, path) do
-      case Map.has_key?(input, key) do
-        false ->
-          {key, {:error, [Conformer.error(path ++ [key], input, ":required")]}}
-
-        true ->
-          val = Map.get(input, key)
-          {key, Conformable.conform(spec, val, path ++ [key])}
+        spec ->
+          {key, Conformable.conform(spec, value, path ++ [key])}
       end
     end
   end
diff --git a/lib/norm/spec/selection.ex b/lib/norm/spec/selection.ex
index 42dbdc1..98dea62 100644
--- a/lib/norm/spec/selection.ex
+++ b/lib/norm/spec/selection.ex
@@ -2,38 +2,122 @@ defmodule Norm.Spec.Selection do
   @moduledoc false
   # Provides the definition for selections
 
-  defstruct subset: nil
+  defstruct required: [], schema: nil
 
   alias Norm.Schema
   alias Norm.SpecError
 
-  def new(schema, path) do
-    select(schema, path, %{})
+  def new(schema, selectors) do
+    # We're going to front load some work so that we can ensure that people are
+    # requiring keys that actually exist in the schema and so that we can make
+    # it easier to conform in the future.
+    # select(schema, path, %{})
+    case selectors do
+      :all ->
+        selectors = build_all_selectors(schema)
+        select(selectors, schema)
+
+      _ ->
+        validate_selectors!(selectors)
+        select(selectors, schema)
+    end
   end
 
-  defp select(_, [], selection), do: %__MODULE__{subset: selection}
-
-  defp select(schema, [selector | rest], selection) do
+  def select(selectors, schema, required \\ [])
+  def select([], schema, required), do: %__MODULE__{schema: schema, required: required}
+  def select([selector | rest], schema, required) do
     case selector do
-      {key, inner} ->
-        case Schema.spec(schema, key) do
-          nil ->
-            raise SpecError, {:selection, key, schema}
-
-          inner_schema ->
-            selection = Map.put(selection, key, select(inner_schema, inner, %{}))
-            select(schema, rest, selection)
-        end
+      {key, inner_keys} ->
+        inner_schema = assert_spec!(schema, key)
+        selection = select(inner_keys, inner_schema)
+        select(rest, schema, [{key, selection} | required])
 
       key ->
-        case Schema.spec(schema, key) do
-          nil ->
-            raise SpecError, {:selection, key, schema}
+        _ = assert_spec!(schema, key)
+        select(rest, schema, [key | required])
+    end
+  end
 
-          spec ->
-            new_selection = Map.put(selection, key, spec)
-            select(schema, rest, new_selection)
+  defp build_all_selectors(schema) do
+    schema.specs
+    |> Enum.map(fn
+      {name, %Schema{}=inner_schema} -> {name, build_all_selectors(inner_schema)}
+      {name, _} -> name
+    end)
+  end
+
+  defp validate_selectors!([]), do: true
+  defp validate_selectors!([{_key, inner} | rest]), do: validate_selectors!(inner) and validate_selectors!(rest)
+  defp validate_selectors!([_key | rest]), do: validate_selectors!(rest)
+  defp validate_selectors!(other), do: raise ArgumentError, "select expects a list of keys but received: #{inspect other}"
+
+  defp assert_spec!(%Schema{}=schema, key) do
+    case Schema.spec(schema, key) do
+      nil -> raise SpecError, {:selection, key, schema}
+      spec -> spec
+    end
+  end
+  defp assert_spec!(%__MODULE__{}, _key) do
+    # In the future we might support this and allow users to overwrite internal
+    # selections. But for now its safer to forbid this.
+    raise ArgumentError, """
+    Attempting to specify a selection on top of another selection is
+    not allowed.
+    """
+  end
+  defp assert_spec!(other, _key) do
+    raise ArgumentError, "Expected a schema and got: #{inspect other}"
+  end
+
+  defimpl Norm.Conformer.Conformable do
+    alias Norm.Conformer
+    alias Norm.Conformer.Conformable
+
+    def conform(%{required: required, schema: schema}, input, path) do
+      with {:ok, conformed} <- Conformable.conform(schema, input, path) do
+        errors = ensure_keys(required, conformed, path, [])
+
+        if Enum.any?(errors) do
+          errors =
+            errors
+            |> Enum.flat_map(fn {_, errors} -> errors end)
+
+          {:error, errors}
+        else
+          # We can just return the conformed values here because we know that
+          # everything is in there.
+          {:ok, conformed}
         end
+      end
+    end
+
+    defp ensure_keys([], _conformed, _path, errors), do: errors
+    defp ensure_keys([{key, inner} | rest], conformed, path, errors) do
+      case ensure_key(key, conformed, path) do
+        :ok ->
+          inner_errors = ensure_keys(inner.required, conformed[key], path ++ [key], errors)
+          ensure_keys(rest, conformed, path, errors ++ inner_errors)
+
+        error ->
+          ensure_keys(rest, conformed, path, [error | errors])
+      end
+    end
+    defp ensure_keys([key | rest], conformed, path, errors) do
+      case ensure_key(key, conformed, path) do
+        :ok ->
+          ensure_keys(rest, conformed, path, errors)
+
+        error ->
+          ensure_keys(rest, conformed, path, [error | errors])
+      end
+    end
+
+    defp ensure_key(key, conformed, path) do
+      if Map.has_key?(conformed, key) do
+        :ok
+      else
+        {:error, [Conformer.error(path ++ [key], conformed, ":required")]}
+      end
     end
   end
 
@@ -41,8 +125,12 @@ defmodule Norm.Spec.Selection do
     defimpl Norm.Generatable do
       alias Norm.Generatable
 
-      def gen(%{subset: specs}) do
-        case Enum.reduce(specs, %{}, &to_gen/2) do
+      # In order to build a semantically meaningful selection we need to generate
+      # all of the specified fields as well as the fields from the underlying
+      # schema. We can then merge both of those maps together with the required
+      # fields taking precedence.
+      def gen(%{required: required, schema: schema}) do
+        case Enum.reduce(required, %{}, & to_gen(&1, schema, &2)) do
           {:error, error} ->
             {:error, error}
 
@@ -51,10 +139,10 @@ defmodule Norm.Spec.Selection do
         end
       end
 
-      defp to_gen(_, {:error, error}), do: {:error, error}
-
-      defp to_gen({key, spec}, generator) do
-        case Generatable.gen(spec) do
+      defp to_gen(_, _schema, {:error, error}), do: {:error, error}
+      # If we're here than we're processing a key with an inner selection.
+      defp to_gen({key, selection}, _schema, generator) do
+        case Generatable.gen(selection) do
           {:ok, g} ->
             Map.put(generator, key, g)
 
@@ -62,37 +150,12 @@ defmodule Norm.Spec.Selection do
             {:error, error}
         end
       end
-    end
-  end
-
-  defimpl Norm.Conformer.Conformable do
-    alias Norm.Conformer
-    alias Norm.Conformer.Conformable
-
-    def conform(%{subset: subset}, input, path) do
-      results =
-        subset
-        |> Enum.map(fn {key, spec} ->
-          val = Map.get(input, key)
-
-          if val do
-            {key, Conformable.conform(spec, val, path ++ [key])}
-          else
-            {key, {:error, [Conformer.error(path ++ [key], input, ":required")]}}
-          end
-        end)
-        |> Enum.reduce(%{ok: [], error: []}, fn {key, {result, r}}, acc ->
-          Map.put(acc, result, [{key, r} | acc[result]])
-        end)
-
-      if Enum.any?(results.error) do
-        errors =
-          results.error
-          |> Enum.flat_map(fn {_, errors} -> errors end)
-
-        {:error, errors}
-      else
-        {:ok, Enum.into(results.ok, %{})}
+      defp to_gen(key, schema, generator) do
+        # Its safe to just get the spec because at this point we *know* that the
+        # keys that have been selected are in the schema.
+        with {:ok, g} <- Generatable.gen(Norm.Schema.spec(schema, key)) do
+          Map.put(generator, key, g)
+        end
       end
     end
   end


Test patch:
diff --git a/test/norm/schema_test.exs b/test/norm/schema_test.exs
index 81a3b61..0034567 100644
--- a/test/norm/schema_test.exs
+++ b/test/norm/schema_test.exs
@@ -28,27 +28,28 @@ defmodule Norm.SchemaTest do
   test "creates a re-usable schema" do
     s = schema(%{name: spec(is_binary())})
     assert %{name: "Chris"} == conform!(%{name: "Chris"}, s)
-    assert {:error, _errors} = conform(%{foo: "bar"}, s)
+    assert %{foo: "bar"} == conform!(%{foo: "bar"}, s)
     assert {:error, _errors} = conform(%{name: 123}, s)
 
     user = schema(%{user: schema(%{name: spec(is_binary())})})
     assert %{user: %{name: "Chris"}} == conform!(%{user: %{name: "Chris"}}, user)
   end
 
-  test "requires all of the keys specified in the schema" do
-    s =
-      schema(%{
-        name: spec(is_binary()),
-        age: spec(is_integer())
-      })
+  test "all keys in a schema are optional" do
+    s = schema(%{name: spec(is_binary()), age: spec(is_integer())})
 
-    assert %{name: "chris", age: 31} == conform!(%{name: "chris", age: 31}, s)
-    assert {:error, errors} = conform(%{name: "chris"}, s)
-    assert errors == [%{spec: ":required", input: %{name: "chris"}, path: [:age]}]
+    assert valid?(%{}, s)
+    assert valid?(%{name: "chris"}, s)
+    assert valid?(%{age: 123}, s)
 
-    user = schema(%{user: s})
-    assert {:error, errors} = conform(%{user: %{age: 31}}, user)
-    assert errors == [%{spec: ":required", input: %{age: 31}, path: [:user, :name]}]
+    refute valid?(%{name: 123}, s)
+    refute valid?(%{age: "11"}, s)
+  end
+
+  test "schemas allow additional keys" do
+    s = schema(%{name: spec(is_binary())})
+
+    assert %{name: "chris", age: 31} == conform!(%{name: "chris", age: 31}, s)
   end
 
   test "works with boolean values" do
@@ -66,39 +67,6 @@ defmodule Norm.SchemaTest do
     assert errors == [%{spec: "is_nil()", input: 123, path: [:foo]}]
   end
 
-  describe "generation" do
-    test "works with maps" do
-      s =
-        schema(%{
-          name: spec(is_binary()),
-          age: spec(is_integer())
-        })
-
-      maps =
-        s
-        |> gen()
-        |> Enum.take(10)
-
-      for map <- maps do
-        assert is_map(map)
-        assert match?(%{name: _, age: _}, map)
-        assert is_binary(map.name)
-        assert is_integer(map.age)
-      end
-    end
-
-    test "returns errors if it contains unknown generators" do
-      s =
-        schema(%{
-          age: spec(&(&1 > 0))
-        })
-
-      assert_raise Norm.GeneratorError, "Unable to create a generator for: &(&1 > 0)", fn ->
-        gen(s)
-      end
-    end
-  end
-
   test "schemas can be composed with other specs" do
     user_or_other = alt(user: User.s(), other: schema(%OtherUser{}))
     user = User.chris()
@@ -128,15 +96,6 @@ defmodule Norm.SchemaTest do
     ]
   end
 
-  test "only returns specced keys" do
-    user_schema =
-      schema(%{
-        name: spec(is_binary())
-      })
-
-    assert {:ok, %{name: "chris"}} == conform(%{name: "chris", age: 31}, user_schema)
-  end
-
   test "works with string keys and atom keys" do
     user =
       schema(%{
@@ -166,7 +125,7 @@ defmodule Norm.SchemaTest do
 
       assert errors == [
         %{spec: "Norm.SchemaTest.User", input: %{age: 31, email: "c@keathley.io", name: "chris"}, path: []}
-             ]
+      ]
     end
 
     test "fails if the wrong struct is passed" do
@@ -176,13 +135,11 @@ defmodule Norm.SchemaTest do
 
       assert errors == [
         %{spec: "Norm.SchemaTest.OtherUser", input: %Norm.SchemaTest.User{age: 31, email: "c@keathley.io", name: "chris"}, path: []}
-             ]
+      ]
     end
 
     test "can create a schema from a struct" do
-      s = schema(%User{})
-
-      assert User.chris() == conform!(User.chris(), s)
+      assert User.chris() == conform!(User.chris(), schema(%User{}))
     end
 
     test "can specify specs for keys" do
@@ -225,4 +182,37 @@ defmodule Norm.SchemaTest do
       end
     end
   end
+
+  describe "generation" do
+    test "works with maps" do
+      s =
+        schema(%{
+          name: spec(is_binary()),
+          age: spec(is_integer())
+        })
+
+      maps =
+        s
+        |> gen()
+        |> Enum.take(10)
+
+      for map <- maps do
+        assert is_map(map)
+        assert match?(%{name: _, age: _}, map)
+        assert is_binary(map.name)
+        assert is_integer(map.age)
+      end
+    end
+
+    test "returns errors if it contains unknown generators" do
+      s =
+        schema(%{
+          age: spec(&(&1 > 0))
+        })
+
+      assert_raise Norm.GeneratorError, "Unable to create a generator for: &(&1 > 0)", fn ->
+        gen(s)
+      end
+    end
+  end
 end
diff --git a/test/norm/selection_test.exs b/test/norm/selection_test.exs
index 3da8bfa..e63073e 100644
--- a/test/norm/selection_test.exs
+++ b/test/norm/selection_test.exs
@@ -2,13 +2,11 @@ defmodule Norm.SelectionTest do
   use ExUnit.Case, async: true
   import Norm
 
-  def user_schema,
-    do:
-      schema(%{
-        name: spec(is_binary()),
-        age: spec(is_integer() and (&(&1 > 0))),
-        email: spec(is_binary() and (&(&1 =~ ~r/@/)))
-      })
+  def user_schema, do: schema(%{
+    name: spec(is_binary()),
+    age: spec(is_integer() and (&(&1 > 0))),
+    email: spec(is_binary() and (&(&1 =~ ~r/@/)))
+  })
 
   @input %{
     name: "chris",
@@ -18,11 +16,10 @@ defmodule Norm.SelectionTest do
 
   describe "selection/2" do
     test "can define selections of schemas" do
-      assert %{age: 31} == conform!(@input, selection(user_schema(), [:age]))
-
-      assert %{age: 31, name: "chris"} ==
-               conform!(@input, selection(user_schema(), [:age, :name]))
-
+      assert @input == conform!(@input, selection(user_schema(), [:age]))
+      assert @input == conform!(@input, selection(user_schema(), [:age, :name]))
+      assert @input == conform!(@input, selection(user_schema(), [:age, :name, :email]))
+      assert @input == conform!(@input, selection(schema(%{name: spec(is_binary())}), [:name]))
       assert {:error, errors} = conform(%{age: -100}, selection(user_schema(), [:age]))
       assert errors == [%{spec: "&(&1 > 0)", input: -100, path: [:age]}]
     end
@@ -40,6 +37,32 @@ defmodule Norm.SelectionTest do
       assert errors == [%{spec: ":required", input: %{fauxuser: %{age: 31}}, path: [:user]}]
     end
 
+    test "works with nested selections"  do
+      user_with_name = schema(%{user: selection(user_schema(), [:name])})
+      input = %{name: "chris"}
+      assert %{user: input} == conform!(%{user: input}, selection(user_with_name))
+
+      assert_raise ArgumentError, fn ->
+        user = schema(%{name: spec(is_binary()), age: spec(is_integer())})
+        required_user = selection(user)
+        selection(schema(%{user: required_user}), [user: [:name]])
+      end
+    end
+
+    test "returns an error if a non map input is given" do
+      assert {:error, errors} = conform(123, selection(user_schema()))
+      assert errors == [
+        %{input: 123, path: [], spec: "not a map"}
+      ]
+    end
+
+    test "if no keys are selected all keys are enforced recursively" do
+      assert valid?(@input, selection(user_schema()))
+      refute valid?(%{}, selection(user_schema()))
+      refute valid?(%{name: "chris"}, selection(user_schema()))
+      refute valid?(%{name: "chris", age: 31}, selection(user_schema()))
+    end
+
     test "errors if there are keys that aren't specified in a schema" do
       assert_raise Norm.SpecError, fn ->
         selection(schema(%{age: spec(is_integer())}), [:name])
@@ -53,14 +76,6 @@ defmodule Norm.SelectionTest do
         selection(schema(%{user: schema(%{age: spec(is_integer())})}), foo: [:name])
       end
     end
-
-    test "allows schemas to grow" do
-      schema = schema(%{user: schema(%{name: spec(is_binary())})})
-      select = selection(schema, user: [:name])
-
-      assert %{user: %{name: "chris"}} ==
-               conform!(%{user: %{name: "chris", age: 31}, foo: :foo}, select)
-    end
   end
 
   describe "generation" do
@@ -108,14 +123,12 @@ defmodule Norm.SelectionTest do
     end
 
     test "can generate inner schemas" do
-      s =
-        schema(%{
-          user:
-            schema(%{
-              name: spec(is_binary()),
-              age: spec(is_integer())
-            })
+      s = schema(%{
+        user: schema(%{
+          name: spec(is_binary()),
+          age: spec(is_integer())
         })
+      })
 
       select = selection(s, user: [:age])
 
diff --git a/test/norm_test.exs b/test/norm_test.exs
index c8647b4..b8545db 100644
--- a/test/norm_test.exs
+++ b/test/norm_test.exs
@@ -48,7 +48,7 @@ defmodule NormTest do
       user = schema(%{name: spec(is_binary()), age: spec(is_integer())})
       ok = {:ok, selection(user, [:name])}
 
-      assert {:ok, %{name: "chris"}} == conform!({:ok, %{name: "chris", age: 31}}, ok)
+      assert {:ok, %{name: "chris", age: 31}} == conform!({:ok, %{name: "chris", age: 31}}, ok)
       assert {:error, errors} = conform({:ok, %{age: 31}}, ok)
       assert errors == [%{spec: ":required", input: %{age: 31}, path: [1, :name]}]
     end
@@ -105,18 +105,6 @@ defmodule NormTest do
     end
   end
 
-  describe "schema/1" do
-    test "creates a re-usable schema" do
-      s = schema(%{name: spec(is_binary())})
-      assert %{name: "Chris"} == conform!(%{name: "Chris"}, s)
-      assert {:error, _errors} = conform(%{foo: "bar"}, s)
-      assert {:error, _errors} = conform(%{name: 123}, s)
-
-      user = schema(%{user: schema(%{name: spec(is_binary())})})
-      assert %{user: %{name: "Chris"}} == conform!(%{user: %{name: "Chris"}}, user)
-    end
-  end
-
   describe "selection/1" do
     test "returns an error if passed a non-schema" do
       assert_raise FunctionClauseError, fn ->
@@ -215,15 +203,18 @@ defmodule NormTest do
         %{name: "andra", age: 30}
       ]
 
-      assert [%{name: "chris"}, %{name: "andra"}] == conform!(input, spec)
+      assert conform!(input, spec) == [
+        %{name: "chris", age: 31, email: "c@keathley.io"},
+        %{name: "andra", age: 30}
+      ]
 
       input = [
-        %{age: 31, email: "c@keathley.io"},
+        %{age: 31, email: "c@keathley.io", name: nil},
         %{name: :andra, age: 30},
       ]
       assert {:error, errors} = conform(input, spec)
       assert errors == [
-        %{spec: ":required", input: %{age: 31, email: "c@keathley.io"}, path: [0, :name]},
+        %{spec: "is_binary()", input: nil, path: [0, :name]},
         %{spec: "is_binary()", input: :andra, path: [1, :name]}
       ]
     end