From 05753f50be951820f0dc7c61f6dfd8dfec6509f0 Mon Sep 17 00:00:00 2001 From: Zach Allaun Date: Tue, 10 Dec 2024 12:07:08 -0500 Subject: [PATCH 1/3] Make bare attributes required by default For example: schema do %{ name: string(), email: string() } end is now equivalent to: schema do %{ required(:name) => string(), required(:email) => string() } end --- lib/drops/type/compiler.ex | 8 ++++-- test/drops/contract/schema_test.exs | 38 +++++++++++++++++++++++++++++ test/test_helper.exs | 5 +++- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/lib/drops/type/compiler.ex b/lib/drops/type/compiler.ex index 8a2675e..041dc4d 100644 --- a/lib/drops/type/compiler.ex +++ b/lib/drops/type/compiler.ex @@ -32,8 +32,12 @@ defmodule Drops.Type.Compiler do def visit(%{} = spec, opts) do keys = - Enum.map(spec, fn {{presence, name}, type_spec} -> - %Key{path: [name], presence: presence, type: visit(type_spec, opts)} + Enum.map(spec, fn + {key, type_spec} when is_atom(key) -> + %Key{path: [key], presence: :required, type: visit(type_spec, opts)} + + {{presence, name}, type_spec} -> + %Key{path: [name], presence: presence, type: visit(type_spec, opts)} end) Map.new(keys, opts) diff --git a/test/drops/contract/schema_test.exs b/test/drops/contract/schema_test.exs index 86fd54d..26d4ad1 100644 --- a/test/drops/contract/schema_test.exs +++ b/test/drops/contract/schema_test.exs @@ -124,6 +124,44 @@ defmodule Drops.Contract.SchemaTest do end end + describe "bare required keys with types" do + contract do + schema do + %{ + :name => type(:string), + :age => type(:integer), + optional(:aliases) => + list(%{ + name: type(:string) + }) + } + end + end + + test "returns success with valid data", %{contract: contract} do + assert {:ok, %{name: "Jane", age: 21}} = contract.conform(%{name: "Jane", age: 21}) + end + + test "defining required keys with types", %{contract: contract} do + assert_errors(["age key must be present"], contract.conform(%{name: "Jane"})) + end + + test "returns error with invalid data", %{contract: contract} do + assert_errors(["name must be a string"], contract.conform(%{name: 312, age: 21})) + end + + test "returns multiple errors with invalid data", %{contract: contract} do + assert_errors( + [ + "age must be an integer", + "aliases.0.name key must be present", + "name must be a string" + ], + contract.conform(%{name: 312, age: "21", aliases: [%{}]}) + ) + end + end + describe "required and optionals keys with types" do contract do schema do diff --git a/test/test_helper.exs b/test/test_helper.exs index 849a798..9d5d3a7 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -8,7 +8,10 @@ defmodule Drops.ContractCase do import Drops.ContractCase def assert_errors(errors, {:error, messages}) do - assert errors == Enum.map(messages, &to_string/1) + assert errors == + messages + |> Enum.map(&to_string/1) + |> Enum.sort() end end end From 3a16b20780c4cf2e585254393576c256d147b2cf Mon Sep 17 00:00:00 2001 From: Zach Allaun Date: Tue, 10 Dec 2024 12:17:56 -0500 Subject: [PATCH 2/3] Update docs to reflect default required attributes --- README.md | 87 ++++++++++++++----------- lib/drops/casters.ex | 8 +-- lib/drops/contract.ex | 40 ++++++------ lib/drops/type.ex | 12 ++-- lib/drops/type/dsl.ex | 6 ++ lib/drops/types/number.ex | 4 +- lib/drops/validator/messages/backend.ex | 14 ++-- 7 files changed, 94 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 80aabea..eefaf67 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Elixir `Drops` is a collection of small modules that provide useful extensions a ## Installation -If [available in Hex](https://hex.pm/docs/publish), the package can be installed by adding `drops` to your list of dependencies in `mix.exs`: +This package can be installed by adding `drops` to your list of dependencies in `mix.exs`: ```elixir def deps do @@ -15,7 +15,7 @@ def deps do end ``` -Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) and published on [HexDocs](https://hexdocs.pm). Once published, the docs can be found at . +Documentation can be found at . ## Contracts @@ -29,8 +29,8 @@ defmodule UserContract do schema do %{ - required(:name) => string(), - required(:email) => string() + name: string(), + email: string() } end end @@ -76,7 +76,7 @@ Contract's schemas are a powerful way of defining the exact shape of the data yo ### Required and optional keys -A schema must explicitly define which keys are required and which are optional. This is done by using `required` and `optional` functions. Here's an example: +Schema attributes are required by defaults and attributes that are optional must be marked explicitly using `optional`. Here's an example: ```elixir defmodule UserContract do @@ -85,7 +85,7 @@ defmodule UserContract do schema do %{ optional(:name) => string(), - required(:email) => string() + :name => string() } end end @@ -97,6 +97,17 @@ UserContract.conform(%{name: "Jane", email: "janedoe.org"}) # {:ok, %{name: "Jane", email: "janedoe.org"}} ``` +If preferred, you can also use `required` to be more explicit. This schema is equivalent to the above: + +```elixir +schema do + %{ + optional(:name) => string(), + required(:name) => string() + } +end +``` + ### Types You can define the expected types of the values using `string`, `integer`, `float`, `boolean`, `atom`, `map`, `list`, `any` and `maybe` functions. Here's an example: @@ -107,12 +118,12 @@ defmodule UserContract do schema do %{ - required(:name) => string(), - required(:age) => integer(), - required(:active) => boolean(), - required(:tags) => list(:string), - required(:settings) => map(:string), - required(:address) => maybe(:string) + name: string(), + age: integer(), + active: boolean(), + tags: list(:string), + settings: map(:string), + address: maybe(:string) } end end @@ -128,8 +139,8 @@ defmodule UserContract do schema do %{ - required(:name) => string(:filled?), - required(:age) => integer(gt?: 18) + name: string(:filled?), + age: integer(gt?: 18) } end end @@ -169,18 +180,18 @@ defmodule UserContract do schema do %{ - required(:user) => %{ - required(:name) => string(:filled?), - required(:age) => integer(), - required(:address) => %{ - required(:city) => string(:filled?), - required(:street) => string(:filled?), - required(:zipcode) => string(:filled?) + user: %{ + name: string(:filled?), + age: integer(), + address: %{ + city: string(:filled?), + street: string(:filled?), + zipcode: string(:filled?) }, - required(:tags) => + tags: list(%{ - required(:name) => string(:filled?), - required(:created_at) => integer() + name: string(:filled?), + created_at: integer() }) } } @@ -255,7 +266,7 @@ defmodule UserContract do schema do %{ - required(:count) => cast(:string) |> integer(gt?: 0) + count: cast(:string) |> integer(gt?: 0) } end end @@ -301,7 +312,7 @@ defmodule UserContract do schema do %{ - required(:text) => cast(:string, caster: CustomCaster) |> string() + text: cast(:string, caster: CustomCaster) |> string() } end end @@ -320,11 +331,11 @@ defmodule UserContract do schema(atomize: true) do %{ - required(:name) => string(), - required(:age) => integer(), - required(:tags) => + name: string(), + age: integer(), + tags: list(%{ - required(:name) => string() + name: string() }) } end @@ -367,8 +378,8 @@ defmodule UserContract do schema do %{ - required(:name) => Types.Name, - required(:age) => Types.Age + name: Types.Name, + age: Types.Age } end end @@ -390,8 +401,8 @@ You can also define reusable schemas, since they are represented as map type: ```elixir defmodule Types.User do use Drops.Type, %{ - required(:name) => string(:filled?), - required(:age) => integer(gteq?: 0) + name: string(:filled?), + age: integer(gteq?: 0) } end @@ -400,7 +411,7 @@ defmodule UserContract do schema do %{ - required(:user) => Types.User + user: Types.User } end end @@ -429,7 +440,7 @@ defmodule ProductContract do schema do %{ - required(:price) => Types.Price + price: Types.Price } end end @@ -463,8 +474,8 @@ defmodule UserContract do schema do %{ - required(:email) => maybe(:string), - required(:login) => maybe(:string) + email: maybe(:string), + login: maybe(:string) } end diff --git a/lib/drops/casters.ex b/lib/drops/casters.ex index 3078e4c..7b108d9 100644 --- a/lib/drops/casters.ex +++ b/lib/drops/casters.ex @@ -15,7 +15,7 @@ defmodule Drops.Casters do ...> use Drops.Contract ...> ...> schema do - ...> %{required(:age) => cast(:string) |> type(:integer)} + ...> %{age: cast(:string) |> type(:integer)} ...> end ...> end iex> UserContract.conform(%{age: "20"}) @@ -27,7 +27,7 @@ defmodule Drops.Casters do ...> use Drops.Contract ...> ...> schema do - ...> %{required(:num) => cast(:string) |> type(:float)} + ...> %{num: cast(:string) |> type(:float)} ...> end ...> end iex> UserContract.conform(%{num: "20.5"}) @@ -39,7 +39,7 @@ defmodule Drops.Casters do ...> use Drops.Contract ...> ...> schema do - ...> %{required(:id) => cast(:integer) |> type(:string)} + ...> %{id: cast(:integer) |> type(:string)} ...> end ...> end iex> UserContract.conform(%{id: 312}) @@ -51,7 +51,7 @@ defmodule Drops.Casters do ...> use Drops.Contract ...> ...> schema do - ...> %{required(:date) => cast(:integer) |> type(:date_time)} + ...> %{date: cast(:integer) |> type(:date_time)} ...> end ...> end iex> UserContract.conform(%{date: 1614556800}) diff --git a/lib/drops/contract.ex b/lib/drops/contract.ex index 87d1613..bbdebf3 100644 --- a/lib/drops/contract.ex +++ b/lib/drops/contract.ex @@ -19,8 +19,8 @@ defmodule Drops.Contract do ...> ...> schema do ...> %{ - ...> required(:name) => type(:string), - ...> required(:age) => type(:integer) + ...> name: type(:string), + ...> age: type(:integer) ...> } ...> end ...> end @@ -126,8 +126,8 @@ defmodule Drops.Contract do ...> ...> schema do ...> %{ - ...> required(:name) => type(:string), - ...> required(:age) => type(:integer) + ...> name: type(:string), + ...> age: type(:integer) ...> } ...> end ...> end @@ -148,13 +148,13 @@ defmodule Drops.Contract do ...> ...> schema(atomize: true) do ...> %{ - ...> required(:user) => %{ - ...> required(:name) => type(:string, [:filled?]), - ...> required(:age) => type(:integer), - ...> required(:address) => %{ - ...> required(:city) => type(:string, [:filled?]), - ...> required(:street) => type(:string, [:filled?]), - ...> required(:zipcode) => type(:string, [:filled?]) + ...> user: %{ + ...> name: type(:string, [:filled?]), + ...> age: type(:integer), + ...> address: %{ + ...> city: type(:string, [:filled?]), + ...> street: type(:string, [:filled?]), + ...> zipcode: type(:string, [:filled?]) ...> } ...> } ...> } @@ -200,18 +200,18 @@ defmodule Drops.Contract do ...> ...> schema(:address) do ...> %{ - ...> required(:street) => string(:filled?), - ...> required(:city) => string(:filled?), - ...> required(:zip) => string(:filled?), - ...> required(:country) => string(:filled?) + ...> street: string(:filled?), + ...> city: string(:filled?), + ...> zip: string(:filled?), + ...> country: string(:filled?) ...> } ...> end ...> ...> schema do ...> %{ - ...> required(:name) => string(), - ...> required(:age) => integer(), - ...> required(:address) => @schemas.address + ...> name: string(), + ...> age: integer(), + ...> address: @schemas.address ...> } ...> end ...> end @@ -271,8 +271,8 @@ defmodule Drops.Contract do ...> ...> schema do ...> %{ - ...> required(:email) => maybe(:string), - ...> required(:login) => maybe(:string) + ...> email: maybe(:string), + ...> login: maybe(:string) ...> } ...> end ...> diff --git a/lib/drops/type.ex b/lib/drops/type.ex index 5cf0fb5..e1e4907 100644 --- a/lib/drops/type.ex +++ b/lib/drops/type.ex @@ -22,7 +22,7 @@ defmodule Drops.Type do ...> ...> schema do ...> %{ - ...> required(:email) => Email + ...> email: Email ...> } ...> end ...> end @@ -51,7 +51,7 @@ defmodule Drops.Type do ...> ...> schema do ...> %{ - ...> required(:email) => FilledEmail + ...> email: FilledEmail ...> } ...> end ...> end @@ -73,8 +73,8 @@ defmodule Drops.Type do defmodule User do use Drops.Type, %{ - required(:name) => string(), - required(:email) => string() + name: string(), + email: string() } end @@ -83,7 +83,7 @@ defmodule Drops.Type do ...> ...> schema do ...> %{ - ...> required(:user) => User + ...> user: User ...> } ...> end ...> end @@ -112,7 +112,7 @@ defmodule Drops.Type do ...> ...> schema do ...> %{ - ...> required(:unit_price) => Price + ...> unit_price: Price ...> } ...> end ...> end diff --git a/lib/drops/type/dsl.ex b/lib/drops/type/dsl.ex index f84d130..5f64e20 100644 --- a/lib/drops/type/dsl.ex +++ b/lib/drops/type/dsl.ex @@ -14,6 +14,12 @@ defmodule Drops.Type.DSL do %{ required(:email) => type(:string) } + + Note that attributes are required by default, so the above is equivalent to: + + %{ + email: type(:string) + } """ @doc since: "0.1.0" @spec required(atom()) :: {:required, atom()} diff --git a/lib/drops/types/number.ex b/lib/drops/types/number.ex index 5b63142..3ceb148 100644 --- a/lib/drops/types/number.ex +++ b/lib/drops/types/number.ex @@ -10,8 +10,8 @@ defmodule Drops.Types.Number do ...> ...> schema do ...> %{ - ...> required(:name) => string(:filled?), - ...> required(:price) => number() + ...> name: string(:filled?), + ...> price: number() ...> } ...> end ...> end diff --git a/lib/drops/validator/messages/backend.ex b/lib/drops/validator/messages/backend.ex index 46b83af..9b7cece 100644 --- a/lib/drops/validator/messages/backend.ex +++ b/lib/drops/validator/messages/backend.ex @@ -20,23 +20,23 @@ defmodule Drops.Validator.Messages.Backend do ...> ...> schema do ...> %{ - ...> required(:name) => string(:filled?), - ...> required(:email) => string(:filled?) + ...> name: string(:filled?), + ...> email: string(:filled?) ...> } ...> end ...> end iex> UserContract.conform(%{name: "", email: 312}) {:error, [ - %Drops.Validator.Messages.Error.Type{ - path: [:email], - text: "312 received but it must be a string", - meta: [predicate: :type?, args: [:string, 312]] - }, %Drops.Validator.Messages.Error.Type{ path: [:name], text: "cannot be empty", meta: [predicate: :filled?, args: [""]] + }, + %Drops.Validator.Messages.Error.Type{ + path: [:email], + text: "312 received but it must be a string", + meta: [predicate: :type?, args: [:string, 312]] } ] } From 54b3b8816b07871e90a06ea5c53b982149bfd0b1 Mon Sep 17 00:00:00 2001 From: Zach Allaun Date: Tue, 10 Dec 2024 12:27:01 -0500 Subject: [PATCH 3/3] Ignore module conflict warnings in tests --- test/test_helper.exs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/test/test_helper.exs b/test/test_helper.exs index 9d5d3a7..24670c2 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -25,19 +25,11 @@ defmodule Drops.ContractCase do unquote(body) end - on_exit(fn -> - :code.purge(__MODULE__.TestContract) - :code.delete(__MODULE__.TestContract) - - # Defined in doctests - :code.purge(__MODULE__.UserContract) - :code.delete(__MODULE__.UserContract) - end) - {:ok, contract: TestContract} end end end end +Code.put_compiler_option(:ignore_module_conflict, true) ExUnit.start()