From f323f3a4c769b80b54c5f92fc507521feb5ff90b Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Mon, 24 Nov 2025 23:29:37 -0500 Subject: [PATCH] add predicates for specifying key and value types of maps --- README.md | 2 +- lib/drops/predicates.ex | 36 ++++++++++ .../validator/messages/default_backend.ex | 12 ++++ test/drops/contract/types/map_test.exs | 65 +++++++++++++++++++ 4 files changed, 114 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ae6fb79..b29677f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/drops/predicates.ex b/lib/drops/predicates.ex index 3e642c3..17b725f 100644 --- a/lib/drops/predicates.ex +++ b/lib/drops/predicates.ex @@ -408,4 +408,40 @@ defmodule Drops.Predicates do """ @spec is_struct?(struct :: atom(), input :: map()) :: boolean() def is_struct?(struct, input) when is_atom(struct) and is_map(input), do: is_struct(input, struct) + + @doc ~S""" + Checks if that the keys of a given map match a given type + + ## Examples + + iex> Drops.Predicates.keys(:string, %{"a" => "b", "c" => "d"}) + true + iex> Drops.Predicates.keys(:string, %{"a" => "b", 1 => "d"}) + false + """ + @spec keys(key_type :: any(), input :: map()) :: boolean() + def keys(key_type, input) when is_map(input) do + key_type = Drops.Type.Compiler.visit(key_type, []) + Enum.all?(input, fn {key, _} -> + Kernel.match?({:ok, _}, Drops.Type.Validator.validate(key_type, key)) + end) + end + + @doc ~S""" + Checks if that the values of a given map match a given type + + ## Examples + + iex> Drops.Predicates.values(:string, %{"a" => "b", "c" => "d"}) + true + iex> Drops.Predicates.values(:string, %{"a" => "b", "c" => 4}) + false + """ + @spec values(value_type :: any(), input :: map()) :: boolean() + def values(value_type, input) when is_map(input) do + value_type = Drops.Type.Compiler.visit(value_type, []) + Enum.all?(input, fn {_, value} -> + Kernel.match?({:ok, _}, Drops.Type.Validator.validate(value_type, value)) + end) + end end diff --git a/lib/drops/validator/messages/default_backend.ex b/lib/drops/validator/messages/default_backend.ex index df212bc..750a880 100644 --- a/lib/drops/validator/messages/default_backend.ex +++ b/lib/drops/validator/messages/default_backend.ex @@ -36,6 +36,8 @@ defmodule Drops.Validator.Messages.DefaultBackend do in?: "must be one of: %input%", not_in?: "must not be one of: %input%", is_struct?: "must be a struct of type %input%", + keys: "must have keys of the right type", + values: "must have values of the right type", # built-in types number: "must be a number", @@ -79,6 +81,16 @@ defmodule Drops.Validator.Messages.DefaultBackend do String.replace(@text_mapping[:not_in?], "%input%", Enum.join(values, ", ")) end + @impl true + def text(:keys, _values, _input) do + @text_mapping[:keys] + end + + @impl true + def text(:values, _values, _input) do + @text_mapping[:values] + end + @impl true def text(:cast, error_message, _input) do error_message diff --git a/test/drops/contract/types/map_test.exs b/test/drops/contract/types/map_test.exs index f2e0517..0cbcf7a 100644 --- a/test/drops/contract/types/map_test.exs +++ b/test/drops/contract/types/map_test.exs @@ -32,4 +32,69 @@ 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(: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, _} = + contract.conform( + %{ + string_to_string: %{"Hello" => "World", "foo" => "bar"}, + 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 must have keys of the right type"], + contract.conform(%{string_to_string: %{1 => "foo"}}) + ) + end + + test "returns error with string => non-string", %{contract: contract} do + assert_errors( + ["string_to_string must have values of the right type"], + 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 must have keys of the right type"], + 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 must have values of the right type"], + contract.conform(%{even_integer_to_filled_string: %{2 => ""}}) + ) + end + + test "returns error with string => non-map", %{contract: contract} do + assert_errors( + ["nested_map must have values of the right type"], + contract.conform(%{nested_map: %{"Hello" => "World!"}}) + ) + end + + test "returns error with string => map with wrong types", %{contract: contract} do + assert_errors( + ["nested_map must have values of the right type"], + contract.conform(%{nested_map: %{"parent" => %{"child" => "grandchild"}}}) + ) + end + end end