Repository: keathley/norm (elixir)

Issue:
`conform` is converting MapSets to Lists in nested schema
Example taken from my code in another issue (#30). 

```elixir
iex> Todo.gen() |> Enum.take(1) |> IO.inspect() |> conform(coll_of(Todo.s()))
[
  %Todo{
    what: "Watch movie",
    when: "Sun",
    who: #MapSet<[
      %Person.Student{name: "Prudence", school: "Academy of the Unseen Arts"},
      %Person.Programmer{
        languages: #MapSet<["Elixir", "Elm", "Haskell"]>,
        name: "Chris",
        university: %Algae.Maybe.Nothing{}
      }
    ]>
  }
]

{:ok,
 [
   %Todo{
     what: "Watch movie",
     when: "Sun",
     who: [
       %Person.Student{name: "Prudence", school: "Academy of the Unseen Arts"},
       %Person.Programmer{
         languages: ["Elixir", "Elm", "Haskell"],
         name: "Chris",
         university: %Algae.Maybe.Nothing{}
       }
     ]
   }
 ]}
```

Issue seems to be when MapSets are nested inside other collections (including lists). For example, here (after revising the code) only `languages` is expected to be a MapSet:

```elixir
iex> Todo.gen() |> Enum.take(1) |> List.first() |> IO.inspect() |> conform(Todo.s())
%Todo{
  what: "Grocery shop",
  when: "Tue",
  who: [
    %Person.Programmer{
      languages: #MapSet<["Cobol", "Elm"]>,
      name: "Wojtek",
      university: %Algae.Maybe.Nothing{}
    },
    %Person.Student{name: "Sabrina", school: "Baxter High School"}
  ]
}

{:ok,
 %Todo{
   what: "Grocery shop",
   when: "Tue",
   who: [
     %Person.Programmer{
       languages: ["Cobol", "Elm"],
       name: "Wojtek",
       university: %Algae.Maybe.Nothing{}
     },
     %Person.Student{name: "Sabrina", school: "Baxter High School"}
   ]
 }
}
```


Pull request:
Conform collection values using the correct types
Closes #47.

Patch:
diff --git a/lib/norm.ex b/lib/norm.ex
index ed79c60..614e181 100644
--- a/lib/norm.ex
+++ b/lib/norm.ex
@@ -583,16 +583,33 @@ defmodule Norm do
   @doc ~S"""
   Specifies a generic collection. Collections can be any enumerable type.
 
+  `coll_of` takes multiple arguments:
+
+  * `:kind` - predicate function the kind of collection being conformed
+  * `:distinct` - boolean value for specifying if the collection should have distinct elements
+  * `:min_count` - Minimum element count
+  * `:max_count` - Maximum element count
+  * `:into` - The output collection the input will be conformed into. If not specified then the input type will be used.
+
   ## Examples
 
       iex> conform!([:a, :b, :c], coll_of(spec(is_atom())))
       [:a, :b, :c]
+      iex> conform!([:a, :b, :c], coll_of(spec(is_atom), into: MapSet.new()))
+      MapSet.new([:a, :b, :c])
+      iex> conform!(MapSet.new([:a, :b, :c]), coll_of(spec(is_atom)))
+      MapSet.new([:a, :b, :c])
+      iex> conform!(%{a: 1, b: 2, c: 3}, coll_of({spec(is_atom), spec(is_integer)}))
+      %{a: 1, b: 2, c: 3}
+      iex> conform!([1, 2], coll_of(spec(is_integer), min_count: 1))
+      [1, 2]
   """
   @default_opts [
+    kind: nil,
     distinct: false,
     min_count: 0,
     max_count: :infinity,
-    into: [],
+    into: nil,
   ]
 
   def coll_of(spec, opts \\ []) do
@@ -613,7 +630,7 @@ defmodule Norm do
       %{a: 1, b: 2, c: 3}
   """
   def map_of(kpred, vpred, opts \\ []) do
-    opts = Keyword.merge(opts, into: %{})
+    opts = Keyword.merge(opts, [into: %{}, kind: &is_map/1])
     coll_of({kpred, vpred}, opts)
   end
 
diff --git a/lib/norm/spec/collection.ex b/lib/norm/spec/collection.ex
index 1fa91e4..56d856b 100644
--- a/lib/norm/spec/collection.ex
+++ b/lib/norm/spec/collection.ex
@@ -13,7 +13,7 @@ defmodule Norm.Spec.Collection do
 
     def conform(%{spec: spec, opts: opts}, input, path) do
       with :ok <- check_enumerable(input, path, opts),
-           :ok <- check_map_of(input, path, opts),
+           :ok <- check_kind_of(input, path, opts),
            :ok <- check_distinct(input, path, opts),
            :ok <- check_counts(input, path, opts) do
         results =
@@ -22,10 +22,27 @@ defmodule Norm.Spec.Collection do
           |> Enum.map(fn {elem, i} -> Conformable.conform(spec, elem, path ++ [i]) end)
           |> Conformer.group_results()
 
+        into = cond do
+          opts[:into] ->
+            opts[:into]
+
+          is_list(input) ->
+            []
+
+          is_map(input) and Map.has_key?(input, :__struct__) ->
+            struct(input.__struct__)
+
+          is_map(input) ->
+            %{}
+
+          true ->
+            raise ArgumentError, "Cannot determine output type for collection"
+        end
+
         if Enum.any?(results.error) do
           {:error, results.error}
         else
-          {:ok, convert(results.ok, opts[:into])}
+          {:ok, convert(results.ok, into)}
         end
       end
     end
@@ -71,18 +88,19 @@ defmodule Norm.Spec.Collection do
       end
     end
 
-    defp check_map_of(input, path, opts) do
+    defp check_kind_of(input, path, opts) do
       cond do
-        # if coll_of was used, accept every kind of list
-        opts[:into] == [] ->
+        # If kind is nil we assume it doesn't matter
+        opts[:kind] == nil ->
           :ok
 
-        # in case of map_of, check the format of the enumerable
-        Enum.all?(input, &match?({_, _}, &1)) ->
+        # If we have a `:kind` and it returns true we pass the spec
+        opts[:kind].(input) ->
           :ok
 
+        # Otherwise return an error
         true ->
-          {:error, [Conformer.error(path, input, "not a map")]}
+          {:error, [Conformer.error(path, input, "does not match kind: #{inspect opts[:kind]}")]}
       end
     end
   end
@@ -113,7 +131,8 @@ defmodule Norm.Spec.Collection do
 
       def into(list_gen, opts) do
         StreamData.bind(list_gen, fn list ->
-          StreamData.constant(Enum.into(list, opts[:into]))
+          # We assume that if we don't have an `into` specified then its a list
+          StreamData.constant(Enum.into(list, opts[:into] || []))
         end)
       end
     end


Test patch:
diff --git a/test/norm_test.exs b/test/norm_test.exs
index 0f10c98..76cc593 100644
--- a/test/norm_test.exs
+++ b/test/norm_test.exs
@@ -166,7 +166,7 @@ defmodule NormTest do
 
       assert {:error, errors} = conform([1, 2, 3], spec)
       assert errors == [
-        %{spec: "not a map", input: [1, 2, 3], path: []}
+        %{spec: "does not match kind: &:erlang.is_map/1", input: [1, 2, 3], path: []}
       ]
     end