Skip to content

Commit

Permalink
Support sum type as an element of the list type
Browse files Browse the repository at this point in the history
  • Loading branch information
IvanRublev committed Apr 29, 2024
1 parent 371eb72 commit a7a876f
Show file tree
Hide file tree
Showing 23 changed files with 733 additions and 631 deletions.
4 changes: 2 additions & 2 deletions .formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ locals_without_parens = [precond: 1, allow: :*, assert_called: :*]
"{mix,.iex,.formatter,.credo}.exs",
"{config,lib}/**/*.{ex,exs}",
"test/*.{ex,exs}",
"test/{domo,support}/*.{ex,exs}",
"test/struct_modules/lib/*.{ex,exs}"
"test/{domo,support}/**/*.{ex,exs}",
"test/struct_modules/lib/**/*.{ex,exs}"
],
locals_without_parens: locals_without_parens,
export: [
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
- otp: 24.3.3
elixir: 1.13.4
- otp: 26.0
elixir: 1.15
elixir: 1.16
steps:
- uses: actions/checkout@v2
- uses: erlef/[email protected]
Expand Down
40 changes: 38 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,8 @@ po
{:ok, %PurchaseOrder{approved_limit: 200, id: 1000, items: [%LineItem{amount: 150}]}}
```

Domo returns the error if the precondition function attached to the `t()` type
that validates invariants for the struct as a whole fails:
Domo returns the error if the precondition function validating the `t()` type
as a whole fails:

```elixir
updated_po = %{po | items: [LineItem.new!(amount: 180), LineItem.new!(amount: 100)]}
Expand All @@ -171,6 +171,37 @@ PurchaseOrder.ensure_type(updated_po)
{:error, [t: "Sum of line item amounts should be <= to approved limit"]}
```

Domo supports sum types for struct fields, for example:

```elixir
defmodule FruitBasket do
use Domo

defstruct fruits: []

@type t() :: %__MODULE__{fruits: [String.t() | :banana]}
end
```

```elixir
FruitBasket.new(fruits: [:banana, "Maracuja"])
```
```output
{:ok, %FruitBasket{fruits: [:banana, "Maracuja"]}}
```

```elixir
{:error, [fruits: message]} = FruitBasket.new(fruits: [:banana, "Maracuja", nil])
IO.puts(message)
```
```output
Invalid value [:banana, "Maracuja", nil] for field :fruits of %FruitBasket{}. Expected the value matching the [<<_::_*8>> | :banana] type.
Underlying errors:
- The element at index 2 has value nil that is invalid.
- Expected the value matching the <<_::_*8>> type.
- Expected the value matching the :banana type.
```

Getting the list of the required fields of the struct that have type other
then `nil` or `any` is like that:

Expand Down Expand Up @@ -794,6 +825,11 @@ Domo compiled validation functions for the given struct based on the described t

## Changelog

## v1.5.15

* Support sum types as element of a list: [a | b]
* Improve compatibility with Elixir 1.16

## v1.5.14

* Fix validate_type/* function to run without Ecto validate_required warning.
Expand Down
1 change: 1 addition & 0 deletions lib/domo/error_builder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ defmodule Domo.ErrorBuilder do
end

def pretty_error({:error, {:type_mismatch, struct_module, field, value, expected_types, error_templates}}, filter_preconds?, bypass_preconds?) do
error_templates = Enum.reject(error_templates, &is_nil/1)
underlying_errors = collect_deepest_underlying_errors(error_templates)

precond_errors =
Expand Down
2 changes: 2 additions & 0 deletions lib/domo/type_ensurer_factory/generator/match_fun_registry.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ defmodule Domo.TypeEnsurerFactory.Generator.MatchFunRegistry do
alias Domo.TypeEnsurerFactory.Generator.MatchFunRegistry.{
Lists,
Literals,
OrElements,
Tuples,
Maps,
Structs
Expand Down Expand Up @@ -63,6 +64,7 @@ defmodule Domo.TypeEnsurerFactory.Generator.MatchFunRegistry do
else
{match_fun, underlying_type_spec_preconds} =
cond do
OrElements.or_element_spec?(type_spec_precond) -> OrElements.match_spec_function_quoted(type_spec_precond)
Lists.list_spec?(type_spec_precond) -> Lists.match_spec_function_quoted(type_spec_precond)
Tuples.tuple_spec?(type_spec_precond) -> Tuples.match_spec_function_quoted(type_spec_precond)
Maps.map_spec?(type_spec_precond) -> Maps.match_spec_function_quoted(type_spec_precond)
Expand Down
78 changes: 39 additions & 39 deletions lib/domo/type_ensurer_factory/generator/match_fun_registry/lists.ex
Original file line number Diff line number Diff line change
Expand Up @@ -152,45 +152,6 @@ defmodule Domo.TypeEnsurerFactory.Generator.MatchFunRegistry.Lists do
{[match_spec_quoted, match_list_elements_quoted], [element_spec_precond]}
end

defp match_el_quoted(type_spec_atom, head_attributes, tail_attributes) do
{head_spec_atom, head_precond_atom, head_spec_string} = head_attributes
{tail_spec_atom, tail_precond_atom, tail_spec_string} = tail_attributes

quote do
def do_match_list_elements(unquote(type_spec_atom), [head | tail], idx, opts) do
case do_match_spec({unquote(head_spec_atom), unquote(head_precond_atom)}, head, unquote(head_spec_string), opts) do
:ok ->
do_match_list_elements(unquote(type_spec_atom), tail, idx + 1, opts)

{:error, element_value, messages} ->
{:element_error,
[
{"The element at index %{idx} has value %{element_value} that is invalid.", [idx: idx, element_value: inspect(element_value)]}
| messages
]}
end
end

def do_match_list_elements(unquote(type_spec_atom), [], idx, _opts) do
{:proper, idx}
end

def do_match_list_elements(unquote(type_spec_atom), tail, idx, opts) do
case do_match_spec({unquote(tail_spec_atom), unquote(tail_precond_atom)}, tail, unquote(tail_spec_string), opts) do
:ok ->
{:improper, idx}

{:error, element_value, messages} ->
{:element_error,
[
{"The tail element has value %{element_value} that is invalid.", [element_value: inspect(element_value)]}
| messages
]}
end
end
end
end

defp match_list_function_quoted(:maybe_improper_list, type_spec, precond) do
{:maybe_improper_list, [], [head_spec_precond, tail_spec_precond]} = type_spec
type_spec_atom = TypeSpec.to_atom(type_spec)
Expand Down Expand Up @@ -277,4 +238,43 @@ defmodule Domo.TypeEnsurerFactory.Generator.MatchFunRegistry.Lists do

{[match_spec_quoted, match_list_elements_quoted], [head_spec_precond, tail_spec_precond]}
end

defp match_el_quoted(type_spec_atom, head_attributes, tail_attributes) do
{head_spec_atom, head_precond_atom, head_spec_string} = head_attributes
{tail_spec_atom, tail_precond_atom, tail_spec_string} = tail_attributes

quote do
def do_match_list_elements(unquote(type_spec_atom), [head | tail], idx, opts) do
case do_match_spec({unquote(head_spec_atom), unquote(head_precond_atom)}, head, unquote(head_spec_string), opts) do
:ok ->
do_match_list_elements(unquote(type_spec_atom), tail, idx + 1, opts)

{:error, element_value, messages} ->
{:element_error,
[
{"The element at index %{idx} has value %{element_value} that is invalid.", [idx: idx, element_value: inspect(element_value)]}
| messages
]}
end
end

def do_match_list_elements(unquote(type_spec_atom), [], idx, _opts) do
{:proper, idx}
end

def do_match_list_elements(unquote(type_spec_atom), tail, idx, opts) do
case do_match_spec({unquote(tail_spec_atom), unquote(tail_precond_atom)}, tail, unquote(tail_spec_string), opts) do
:ok ->
{:improper, idx}

{:error, element_value, messages} ->
{:element_error,
[
{"The tail element has value %{element_value} that is invalid.", [element_value: inspect(element_value)]}
| messages
]}
end
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
defmodule Domo.TypeEnsurerFactory.Generator.MatchFunRegistry.OrElements do
@moduledoc false

alias Domo.TypeEnsurerFactory.Precondition
alias Domo.TypeEnsurerFactory.Generator.TypeSpec

def or_element_spec?(type_spec_precond) do
{type_spec, _precond} = TypeSpec.split_spec_precond(type_spec_precond)
match?({:|, _, [_ | _]}, type_spec)
end

def match_spec_function_quoted(type_spec_precond) do
{type_spec, precond} = TypeSpec.split_spec_precond(type_spec_precond)

{:|, _, elements_spec_precond} = type_spec

type_spec_atom = TypeSpec.to_atom(type_spec)
precond_atom = if precond, do: Precondition.to_atom(precond)
spec_string_var = if precond, do: quote(do: spec_string), else: quote(do: _spec_string)

[l_elem_spec_precond, r_elem_spec_precond] = elements_spec_precond
{l_elem_spec_atom, l_elem_precond_atom, l_elem_spec_string} = TypeSpec.match_spec_attributes(l_elem_spec_precond)
{r_elem_spec_atom, r_elem_precond_atom, r_elem_spec_string} = TypeSpec.match_spec_attributes(r_elem_spec_precond)

match_spec_quoted =
quote do
def do_match_spec({unquote(type_spec_atom), unquote(precond_atom)}, value, unquote(spec_string_var), opts) do
reply1 = do_match_spec({unquote(l_elem_spec_atom), unquote(l_elem_precond_atom)}, value, unquote(l_elem_spec_string), opts)

if :ok == reply1 do
unquote(Precondition.ok_or_precond_call_quoted(precond, quote(do: spec_string), quote(do: value)))
else
reply2 = do_match_spec({unquote(r_elem_spec_atom), unquote(r_elem_precond_atom)}, value, unquote(r_elem_spec_string), opts)

case reply2 do
:ok ->
unquote(Precondition.ok_or_precond_call_quoted(precond, quote(do: spec_string), quote(do: value)))

{:error, value, _messages} ->
# find which reply has precondition error and use that one, or no error at all
{:error, _value, messages1} = reply1
{:error, _value, messages2} = reply2

# we join all errors together
messages = messages1 ++ messages2

# we add nil to the error list to make the error builder function to form
# a general message about mismatching | sum type
{:error, value, [nil | messages]}
end
end
end
end

{[match_spec_quoted], elements_spec_precond}
end

def map_value_type(type_spec_precond, fun) do
{type_spec, precond} = TypeSpec.split_spec_precond(type_spec_precond)

{:|, _, elements_spec_precond} = type_spec

elements_spec_precond =
Enum.map(elements_spec_precond, fn case_spec_precond ->
fun.(case_spec_precond)
end)

{{:|, [], elements_spec_precond}, precond}
end
end
4 changes: 4 additions & 0 deletions lib/domo/type_ensurer_factory/generator/type_spec.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ defmodule Domo.TypeEnsurerFactory.Generator.TypeSpec do

alias Domo.TypeEnsurerFactory.Generator.MatchFunRegistry.{
Lists,
OrElements,
Tuples,
Maps
}
Expand Down Expand Up @@ -40,6 +41,9 @@ defmodule Domo.TypeEnsurerFactory.Generator.TypeSpec do
def filter_preconds(type_spec_precond) do
result =
cond do
OrElements.or_element_spec?(type_spec_precond) ->
OrElements.map_value_type(type_spec_precond, &filter_preconds/1)

Lists.list_spec?(type_spec_precond) ->
Lists.map_value_type(type_spec_precond, &filter_preconds/1)

Expand Down
Loading

0 comments on commit a7a876f

Please sign in to comment.