From 6cdacec6bcc3822658fbd490936a4a522d071369 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Fri, 26 Jan 2024 09:47:24 +0100 Subject: [PATCH 1/2] Add built-in Number type --- lib/drops/contract.ex | 2 +- lib/drops/type.ex | 10 ++- lib/drops/type/compiler.ex | 5 ++ lib/drops/type/dsl.ex | 27 +++++++ lib/drops/types/cast.ex | 2 +- lib/drops/types/map/key.ex | 6 +- lib/drops/types/number.ex | 12 ++++ lib/drops/types/union.ex | 57 +++++++++++---- lib/drops/validator/messages/backend.ex | 26 +++++-- .../validator/messages/default_backend.ex | 10 ++- test/contract/types/number_test.exs | 71 +++++++++++++++++++ 11 files changed, 199 insertions(+), 29 deletions(-) create mode 100644 lib/drops/types/number.ex create mode 100644 test/contract/types/number_test.exs diff --git a/lib/drops/contract.ex b/lib/drops/contract.ex index 9ce40d4..0360c81 100644 --- a/lib/drops/contract.ex +++ b/lib/drops/contract.ex @@ -90,7 +90,7 @@ defmodule Drops.Contract do {:error, right_error} -> {:error, @message_backend.errors( - {:error, {path, {:or, {left_error, right_error}}}} + {:error, {path, {:or, {left_error, right_error, type.opts}}}} )} end end diff --git a/lib/drops/type.ex b/lib/drops/type.ex index 2560b41..8be8275 100644 --- a/lib/drops/type.ex +++ b/lib/drops/type.ex @@ -89,14 +89,18 @@ defmodule Drops.Type do @type t :: %__MODULE__{} - defstruct(unquote(attributes)) + Module.register_attribute(__MODULE__, :type_spec, accumulate: false) + Module.register_attribute(__MODULE__, :opts, accumulate: false) + + @opts [] + + defstruct(unquote(attributes) ++ [opts: @opts]) end end defmacro deftype(primitive, attributes) when is_atom(primitive) do all_attrs = - [primitive: primitive, constraints: Type.infer_constraints(primitive)] ++ - attributes + [primitive: primitive, constraints: Type.infer_constraints(primitive)] ++ attributes quote do deftype(unquote(all_attrs)) 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 99373fc..f84d130 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/cast.ex b/lib/drops/types/cast.ex index f57c415..8402222 100644 --- a/lib/drops/types/cast.ex +++ b/lib/drops/types/cast.ex @@ -26,7 +26,7 @@ defmodule Drops.Types.Cast do alias Drops.Casters use Drops.Type do - deftype([:input_type, :output_type, opts: []]) + deftype([:input_type, :output_type]) def new(input_type, output_type, opts) do struct(__MODULE__, input_type: input_type, output_type: output_type, opts: opts) 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..bfb5eee --- /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 + """ + + use(Drops.Type, union([:integer, :float])) + + @opts name: :number +end diff --git a/lib/drops/types/union.ex b/lib/drops/types/union.ex index 8baed90..2c49eaf 100644 --- a/lib/drops/types/union.ex +++ b/lib/drops/types/union.ex @@ -20,7 +20,10 @@ defmodule Drops.Types.Union do """ defmodule Validator do - def validate(%{left: %{primitive: _} = left, right: %{primitive: _} = right}, input) do + def validate( + %{left: %{primitive: _} = left, right: %{primitive: _} = right} = type, + input + ) do case Drops.Type.Validator.validate(left, input) do {:ok, value} -> {:ok, value} @@ -34,13 +37,13 @@ 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 end - 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} @@ -51,7 +54,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 @@ -60,20 +63,13 @@ defmodule Drops.Types.Union do defmacro __using__(spec) do quote do use Drops.Type do - deftype([:left, :right, :opts]) + deftype([:left, :right]) - alias Drops.Type.Compiler import Drops.Types.Union - def new(opts) do - {:union, {left, right}} = unquote(spec) + @type_spec unquote(spec) - struct(__MODULE__, %{ - left: Compiler.visit(left, opts), - right: Compiler.visit(right, opts), - opts: opts - }) - end + @before_compile Drops.Types.Union defimpl Drops.Type.Validator, for: __MODULE__ do def validate(type, data), do: Validator.validate(type, data) @@ -82,8 +78,39 @@ defmodule Drops.Types.Union do end end + defmacro __before_compile__(_env) do + quote do + alias Drops.Type.Compiler + + 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}} = @type_spec + + struct(__MODULE__, %{ + left: Compiler.visit(left, opts), + right: Compiler.visit(right, opts), + opts: Keyword.merge(@opts, opts) + }) + end + + defp constrain(type, predicates) do + Map.merge(type, %{ + constraints: type.constraints ++ infer_constraints(predicates) + }) + end + end + end + use Drops.Type do - deftype([:left, :right, :opts]) + deftype([:left, :right]) def new(left, right) when is_struct(left) and is_struct(right) do struct(__MODULE__, left: left, right: right) diff --git a/lib/drops/validator/messages/backend.ex b/lib/drops/validator/messages/backend.ex index 0119db7..46b83af 100644 --- a/lib/drops/validator/messages/backend.ex +++ b/lib/drops/validator/messages/backend.ex @@ -136,12 +136,26 @@ 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: Keyword.get(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..7f1255d --- /dev/null +++ b/test/contract/types/number_test.exs @@ -0,0 +1,71 @@ +defmodule Drops.Contract.Types.NumberTest 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 From f8211181fcacfee5281a6b850c38ae66eb352340 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 30 Jan 2024 08:26:08 +0100 Subject: [PATCH 2/2] Add basic doctest for Number --- examples/types/number-01.ex | 17 +++++++++++++++++ lib/drops/types/number.ex | 27 +++++++++++++++++++++++++++ test/contract/types/number_test.exs | 2 ++ 3 files changed, 46 insertions(+) create mode 100644 examples/types/number-01.ex diff --git a/examples/types/number-01.ex b/examples/types/number-01.ex new file mode 100644 index 0000000..ebeb784 --- /dev/null +++ b/examples/types/number-01.ex @@ -0,0 +1,17 @@ +defmodule ProductContract do + use Drops.Contract + + schema do + %{ + required(:name) => string(:filled?), + required(:price) => number() + } + end +end + +ProductContract.conform(%{name: "Book", price: 31.2}) + +ProductContract.conform(%{name: "Book", price: 31}) + +{:error, errors} = ProductContract.conform(%{name: "Book", price: []}) +Enum.map(errors, &to_string/1) diff --git a/lib/drops/types/number.ex b/lib/drops/types/number.ex index bfb5eee..5b63142 100644 --- a/lib/drops/types/number.ex +++ b/lib/drops/types/number.ex @@ -4,7 +4,34 @@ defmodule Drops.Types.Number do that can be either an integer or a float ## Examples + + iex> defmodule ProductContract do + ...> use Drops.Contract + ...> + ...> schema do + ...> %{ + ...> required(:name) => string(:filled?), + ...> required(:price) => number() + ...> } + ...> end + ...> end + iex> ProductContract.conform(%{name: "Book", price: 31.2}) + {:ok, %{name: "Book", price: 31.2}} + iex> ProductContract.conform(%{name: "Book", price: 31}) + {:ok, %{name: "Book", price: 31}} + iex> {:error, errors} = ProductContract.conform(%{name: "Book", price: []}) + {:error, + [ + %Drops.Validator.Messages.Error.Type{ + path: [:price], + text: "must be a number", + meta: [] + } + ]} + iex> Enum.map(errors, &to_string/1) + ["price must be a number"] """ + @doc since: "0.2.0" use(Drops.Type, union([:integer, :float])) diff --git a/test/contract/types/number_test.exs b/test/contract/types/number_test.exs index 7f1255d..b4f8eb7 100644 --- a/test/contract/types/number_test.exs +++ b/test/contract/types/number_test.exs @@ -1,6 +1,8 @@ defmodule Drops.Contract.Types.NumberTest do use Drops.ContractCase + doctest Drops.Types.Number + describe "number/0" do contract do schema do