Skip to content

Commit

Permalink
Merge pull request #41 from solnic/number-type
Browse files Browse the repository at this point in the history
Add built-in number type
  • Loading branch information
solnic authored Jan 30, 2024
2 parents 1055522 + f821118 commit 3be9fd7
Show file tree
Hide file tree
Showing 12 changed files with 245 additions and 29 deletions.
17 changes: 17 additions & 0 deletions examples/types/number-01.ex
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion lib/drops/contract.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions lib/drops/type.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
5 changes: 5 additions & 0 deletions lib/drops/type/compiler.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ defmodule Drops.Type.Compiler do
alias Drops.Types.{
Primitive,
Union,
Number,
List,
Cast,
Map,
Expand All @@ -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))
Expand Down
27 changes: 27 additions & 0 deletions lib/drops/type/dsl.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion lib/drops/types/cast.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 4 additions & 2 deletions lib/drops/types/map/key.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions lib/drops/types/number.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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
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]))

@opts name: :number
end
57 changes: 42 additions & 15 deletions lib/drops/types/union.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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}
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand Down
26 changes: 20 additions & 6 deletions lib/drops/validator/messages/backend.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion lib/drops/validator/messages/default_backend.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
73 changes: 73 additions & 0 deletions test/contract/types/number_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
defmodule Drops.Contract.Types.NumberTest do
use Drops.ContractCase

doctest Drops.Types.Number

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

0 comments on commit 3be9fd7

Please sign in to comment.