Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ defmodule UserContract do
age: integer(),
active: boolean(),
tags: list(:string),
settings: map(:string),
settings: map(values: :string),
address: maybe(:string)
}
end
Expand Down
12 changes: 12 additions & 0 deletions lib/drops/type/compiler.ex
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ defmodule Drops.Type.Compiler do
List.new(visit({:type, {:any, []}}, opts), predicates)
end

def visit({:type, {:map, predicates}} = spec, _opts) do
if Enum.any?(predicates, fn
{:keys, _} -> true
{:values, _} -> true
_ -> false
end) do
Drops.Types.TypedMap.new(predicates)
else
Primitive.new(spec)
end
end

def visit({:cast, {input_type, output_type, cast_opts}}, opts) do
Cast.new(visit(input_type, opts), visit(output_type, opts), cast_opts)
end
Expand Down
12 changes: 12 additions & 0 deletions lib/drops/type/dsl.ex
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,18 @@ defmodule Drops.Type.DSL do
{:type, {type, predicates ++ more_predicates}}
end

def type(:map, predicates) when is_list(predicates) do
if Enum.any?(predicates, fn
{:keys, _} -> true
{:values, _} -> true
_ -> false
end) do
Drops.Types.TypedMap.new(predicates)
else
{:type, {:map, predicates}}
end
end

def type(type, predicates) when is_atom(type) and is_list(predicates) do
{:type, {type, predicates}}
end
Expand Down
13 changes: 7 additions & 6 deletions lib/drops/types/map/key.ex
Original file line number Diff line number Diff line change
Expand Up @@ -42,29 +42,30 @@ defmodule Drops.Types.Map.Key do
Map.has_key?(map, key) and present?(map[key], tail)
end

defp nest_result({:error, {:or, {left, right, opts}}}, root) do
@doc false
def nest_result({:error, {:or, {left, right, opts}}}, root) do
{:error,
{:or,
{nest_result(left, root), nest_result(right, root), Keyword.merge(opts, path: root)}}}
end

defp nest_result({:error, {:list, results}}, root) when is_list(results) do
def nest_result({:error, {:list, results}}, root) when is_list(results) do
{:error, {root, {:list, Enum.with_index(results, &nest_result(&1, root ++ [&2]))}}}
end

defp nest_result({:error, {:list, result}}, root) when is_tuple(result) do
def nest_result({:error, {:list, result}}, root) when is_tuple(result) do
{:error, {root, result}}
end

defp nest_result(results, root) when is_list(results) do
def nest_result(results, root) when is_list(results) do
Enum.map(results, &nest_result(&1, root))
end

defp nest_result({outcome, {path, result}}, root) when is_list(path) do
def nest_result({outcome, {path, result}}, root) when is_list(path) do
{outcome, {root ++ path, result}}
end

defp nest_result({outcome, value}, root) do
def nest_result({outcome, value}, root) do
{outcome, {root, value}}
end
end
88 changes: 88 additions & 0 deletions lib/drops/types/typed_map.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
defmodule Drops.Types.TypedMap do
@moduledoc ~S"""
Drops.Types.TypedMap is a struct that represents a map in which keys and/or
values have a specified type.

## Examples

iex> Drops.Type.Compiler.visit(
...> {
...> :type,
...> {:map, [keys: {:type, {:integer, []}}, values: {:type, {:string, []}}]}
...> },
...> []
...> )
Drops.Types.TypedMap{
key_type: %Drops.Types.Primitive{
primitive: :integer,
constraints: [predicate: {:type?, [:integer]}],
opts: []
},
value_type: %Drops.Types.Primitive{
primitive: :string,
constraints: [predicate: {:type?, [:string]}],
opts: []
},
constraints: [predicate: {:type?, [:map]}],
opts: []
}

"""

alias __MODULE__
use Drops.Type do
deftype(
key_type: :any,
value_type: :any,
constraints: type(:map)
)

def new(predicates) when is_list(predicates) do
{keys, values, predicates} =
Enum.reduce(
predicates,
{:any, :any, [type?: [:map]]},
fn
{:keys, keys}, {_, values, predicates} -> {Drops.Type.Compiler.visit(keys, []), values, predicates}
{:values, values}, {keys, _, predicates} -> {keys, Drops.Type.Compiler.visit(values, []), predicates}
predicate, {keys, values, predicates} -> {keys, values, [predicate | predicates]}
end
)
%TypedMap {
key_type: keys,
value_type: values,
constraints: infer_constraints(Enum.reverse(predicates))
}
end

defimpl Drops.Type.Validator do
def validate(type, value) do
with {:ok, value} <- Drops.Predicates.Helpers.apply_predicates(value, type.constraints) do
{values, errors} =
Enum.reduce(
value,
{[], []},
fn {key, val}, {values, errors} ->
with {:ok, key} <- Drops.Type.Validator.validate(type.key_type, key),
{:ok, _val} = result <- Drops.Type.Validator.validate(type.value_type, val) do
{[Drops.Types.Map.Key.nest_result(result, [key]) | values], errors}
else
{:error, _} = err ->
{value, [Drops.Types.Map.Key.nest_result(err, [key]) | errors]}
end
end
)
if Enum.empty?(errors),
do: {:ok, {:map, values}},
else: {:error, {:map, errors}}
else
{:error, {value, meta}} ->
{:error, Keyword.merge([input: value], meta)}

error ->
error
end
end
end
end
end
72 changes: 72 additions & 0 deletions test/drops/contract/types/map_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,76 @@ defmodule Drops.Contract.Types.MapTest do
assert_errors(["test must be a map"], contract.conform(%{test: 312}))
end
end

describe "map/1 with type specification" do
contract do
schema do
%{
optional(:string_to_string) => map(keys: string(), values: string()),
optional(:string_to_integer_as_string) => map(keys: string(), values: cast(:string) |> integer()),
optional(:even_integer_to_filled_string) => map(keys: integer(:even?), values: string(:filled?)),
optional(:nested_map) => map(keys: string(), values: map(keys: string(), values: list(string())))
}
end
end

test "returns success with maps with correct types", %{contract: contract} do
assert {:ok, %{
string_to_string: %{"Hello" => "World", "foo" => "bar"},
string_to_integer_as_string: %{"foo" => 1, "bar" => 2},
even_integer_to_filled_string: %{2 => "baz"},
nested_map: %{"parent" => %{"child" => ["grandchild1", "grandchild2"]}}
}} ==
contract.conform(
%{
string_to_string: %{"Hello" => "World", "foo" => "bar"},
string_to_integer_as_string: %{"foo" => "1", "bar" => "2"},
even_integer_to_filled_string: %{2 => "baz"},
nested_map: %{"parent" => %{"child" => ["grandchild1", "grandchild2"]}}
}
)
end

test "returns error with non-string => string", %{contract: contract} do
assert_errors(
["string_to_string.1 must be a string"],
contract.conform(%{string_to_string: %{1 => "foo"}})
)
end

test "returns error with string => non-string", %{contract: contract} do
assert_errors(
["string_to_string.foo must be a string"],
contract.conform(%{string_to_string: %{"foo" => true}})
)
end

test "returns error with odd integer => string", %{contract: contract} do
assert_errors(
["even_integer_to_filled_string.1 must be even"],
contract.conform(%{even_integer_to_filled_string: %{1 => "foo"}})
)
end

test "returns error with even integer => empty string", %{contract: contract} do
assert_errors(
["even_integer_to_filled_string.2 must be filled"],
contract.conform(%{even_integer_to_filled_string: %{2 => ""}})
)
end

test "returns error with string => non-map", %{contract: contract} do
assert_errors(
["nested_map.Hello must be a map"],
contract.conform(%{nested_map: %{"Hello" => "World!"}})
)
end

test "returns error with string => map with wrong types", %{contract: contract} do
assert_errors(
["nested_map.parent.child must be a list"],
contract.conform(%{nested_map: %{"parent" => %{"child" => "grandchild"}}})
)
end
end
end
Loading