Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add built-in number type #41

Merged
merged 2 commits into from
Jan 30, 2024
Merged
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
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
Loading