From 66fcf6e7e6134526769f8c6407f54ec86f48e8b2 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Fri, 26 Jan 2024 09:47:24 +0100 Subject: [PATCH] WIP --- lib/drops/type/compiler.ex | 5 ++ lib/drops/type/dsl.ex | 27 +++++++ lib/drops/types/map/key.ex | 6 +- lib/drops/types/number.ex | 12 ++++ lib/drops/types/sum.ex | 21 +++++- lib/drops/validator/messages/backend.ex | 23 ++++-- .../validator/messages/default_backend.ex | 10 ++- test/contract/types/number_test.exs | 71 +++++++++++++++++++ 8 files changed, 163 insertions(+), 12 deletions(-) create mode 100644 lib/drops/types/number.ex create mode 100644 test/contract/types/number_test.exs diff --git a/lib/drops/type/compiler.ex b/lib/drops/type/compiler.ex index 4661930..979af23 100644 --- a/lib/drops/type/compiler.ex +++ b/lib/drops/type/compiler.ex @@ -6,6 +6,7 @@ defmodule Drops.Type.Compiler do alias Drops.Types.{ Primitive, Union, + Number, List, Cast, Map, @@ -27,6 +28,10 @@ defmodule Drops.Type.Compiler do Union.new(visit(left, opts), visit(right, opts)) end + def visit({:type, {:number, predicates}}, opts) do + Number.new(predicates, opts) + end + def visit({:type, {:list, member_type}}, opts) when is_tuple(member_type) or is_map(member_type) do List.new(visit(member_type, opts)) diff --git a/lib/drops/type/dsl.ex b/lib/drops/type/dsl.ex index 6d9cb91..25d02bf 100644 --- a/lib/drops/type/dsl.ex +++ b/lib/drops/type/dsl.ex @@ -377,6 +377,33 @@ defmodule Drops.Type.DSL do type(cast_spec, float(predicates)) end + @doc ~S""" + Returns a number type specification. + + ## Examples + + # a number with no constraints + number() + + # a number with constraints + number(gt?: 1.0) + + """ + + @spec number() :: type() + + def number() do + type(:number) + end + + def number(predicate) when is_atom(predicate) do + type(:number, [predicate]) + end + + def number(predicates) when is_list(predicates) do + type(:number, predicates) + end + @doc ~S""" Returns a boolean type specification. diff --git a/lib/drops/types/map/key.ex b/lib/drops/types/map/key.ex index eae317c..0fe81ba 100644 --- a/lib/drops/types/map/key.ex +++ b/lib/drops/types/map/key.ex @@ -42,8 +42,10 @@ defmodule Drops.Types.Map.Key do Map.has_key?(map, key) and present?(map[key], tail) end - defp nest_result({:error, {:or, {left, right}}}, root) do - {:error, {:or, {nest_result(left, root), nest_result(right, root)}}} + defp 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 diff --git a/lib/drops/types/number.ex b/lib/drops/types/number.ex new file mode 100644 index 0000000..145ac01 --- /dev/null +++ b/lib/drops/types/number.ex @@ -0,0 +1,12 @@ +defmodule Drops.Types.Number do + @moduledoc ~S""" + Drops.Types.Number is a struct that represents a number type + that can be either an integer or a float + + ## Examples + """ + + @opts name: :number + + use(Drops.Type, union([:integer, :float])) +end diff --git a/lib/drops/types/sum.ex b/lib/drops/types/sum.ex index 19f8cdf..0f6e2bb 100644 --- a/lib/drops/types/sum.ex +++ b/lib/drops/types/sum.ex @@ -20,7 +20,7 @@ defmodule Drops.Types.Union do """ defmodule Validator do - def validate(%{left: left, right: right}, input) do + def validate(%{left: left, right: right} = type, input) do case Drops.Type.Validator.validate(left, input) do {:ok, value} -> {:ok, value} @@ -31,7 +31,7 @@ defmodule Drops.Types.Union do {:ok, value} {:error, _} = right_error -> - {:error, {:or, {left_error, right_error}}} + {:error, {:or, {left_error, right_error, type.opts}}} end end end @@ -45,13 +45,28 @@ defmodule Drops.Types.Union do alias Drops.Type.Compiler import Drops.Types.Union + def new(predicates, opts) do + type = new(Keyword.merge(@opts, opts)) + + Map.merge(type, %{ + left: constrain(type.left, predicates), + right: constrain(type.right, predicates) + }) + end + def new(opts) do {:union, {left, right}} = unquote(spec) struct(__MODULE__, %{ left: Compiler.visit(left, opts), right: Compiler.visit(right, opts), - opts: opts + opts: Keyword.merge(@opts, opts) + }) + end + + defp constrain(type, predicates) do + Map.merge(type, %{ + constraints: type.constraints ++ infer_constraints(predicates) }) end diff --git a/lib/drops/validator/messages/backend.ex b/lib/drops/validator/messages/backend.ex index 0119db7..88b3f10 100644 --- a/lib/drops/validator/messages/backend.ex +++ b/lib/drops/validator/messages/backend.ex @@ -136,12 +136,23 @@ defmodule Drops.Validator.Messages.Backend do %Error.Rule{path: path, text: text} end - defp error({:error, {:or, {left, right}}}) do - %Error.Union{left: error(left), right: error(right)} - end - - defp error({:error, {path, {:or, {left, right}}}}) do - nest(error({:error, {:or, {left, right}}}), path) + defp error({:error, {:or, {left, right, opts}}}) do + if not is_nil(opts[:name]) and not is_nil(opts[:path]) do + meta = Keyword.drop(opts, [:name, :path]) + + %Error.Type{path: opts[:path], text: text(opts[:name], opts), meta: meta} + else + %Error.Union{left: error(left), right: error(right)} + end + end + + defp error({:error, {path, {:or, {left, right, opts}}}}) do + nest( + error( + {:error, {:or, {left, right, Keyword.merge(opts, path: opts[:path] ++ path)}}} + ), + path + ) end defp error({:error, {path, {:cast, error}}}) do diff --git a/lib/drops/validator/messages/default_backend.ex b/lib/drops/validator/messages/default_backend.ex index 35cb8e6..7c14fe5 100644 --- a/lib/drops/validator/messages/default_backend.ex +++ b/lib/drops/validator/messages/default_backend.ex @@ -34,9 +34,17 @@ defmodule Drops.Validator.Messages.DefaultBackend do includes?: "must include %input%", excludes?: "must exclude %input%", in?: "must be one of: %input%", - not_in?: "must not be one of: %input%" + not_in?: "must not be one of: %input%", + + # built-in types + number: "must be a number" } + @impl true + def text(:number, _opts) do + @text_mapping[:number] + end + @impl true def text(predicate, _input) do @text_mapping[predicate] diff --git a/test/contract/types/number_test.exs b/test/contract/types/number_test.exs new file mode 100644 index 0000000..67231d1 --- /dev/null +++ b/test/contract/types/number_test.exs @@ -0,0 +1,71 @@ +defmodule Drops.Contract.Types.IntegerTest do + use Drops.ContractCase + + describe "number/0" do + contract do + schema do + %{required(:test) => number()} + end + end + + test "returns success with valid data", %{contract: contract} do + assert {:ok, %{test: 312}} = contract.conform(%{test: 312}) + end + + test "returns error with invalid data", %{contract: contract} do + assert_errors(["test must be a number"], contract.conform(%{test: :invalid})) + end + end + + describe "number/1 with an extra predicate" do + contract do + schema do + %{required(:test) => number(:odd?)} + end + end + + test "returns success with valid data", %{contract: contract} do + assert {:ok, %{test: 311}} = contract.conform(%{test: 311}) + end + + test "returns error with invalid data", %{contract: contract} do + assert_errors(["test must be a number"], contract.conform(%{test: :invalid})) + assert_errors(["test must be odd"], contract.conform(%{test: 312})) + end + end + + describe "number/1 with an extra predicate with args" do + contract do + schema do + %{required(:test) => number(gt?: 2)} + end + end + + test "returns success with valid data", %{contract: contract} do + assert {:ok, %{test: 312}} = contract.conform(%{test: 312}) + end + + test "returns error with invalid data", %{contract: contract} do + assert_errors(["test must be a number"], contract.conform(%{test: :invalid})) + assert_errors(["test must be greater than 2"], contract.conform(%{test: 0})) + end + end + + describe "number/1 with extra predicates" do + contract do + schema do + %{required(:test) => number([:even?, gt?: 2])} + end + end + + test "returns success with valid data", %{contract: contract} do + assert {:ok, %{test: 312}} = contract.conform(%{test: 312}) + end + + test "returns error with invalid data", %{contract: contract} do + assert_errors(["test must be a number"], contract.conform(%{test: :invalid})) + assert_errors(["test must be even"], contract.conform(%{test: 311})) + assert_errors(["test must be greater than 2"], contract.conform(%{test: 0})) + end + end +end