defmodule Domo do
@moduledoc Domo.Doc.readme_doc("<!-- Documentation -->")
@using_options Domo.Doc.readme_doc("<!-- using_options -->")
@new_raise_doc Domo.Doc.readme_doc("<!-- new!/1 -->")
@new_ok_doc Domo.Doc.readme_doc("<!-- new/2 -->")
@ensure_type_raise_doc Domo.Doc.readme_doc("<!-- ensure_type!/1 -->")
@ensure_type_ok_doc Domo.Doc.readme_doc("<!-- ensure_type/2 -->")
@typed_fields_doc Domo.Doc.readme_doc("<!-- typed_fields/1 -->")
@required_fields_doc Domo.Doc.readme_doc("<!-- required_fields/1 -->")
@callback new!() :: struct()
@doc @new_raise_doc
@callback new!(enumerable :: Enumerable.t()) :: struct()
@callback new() :: {:ok, struct()} | {:error, any()}
@callback new(enumerable :: Enumerable.t()) :: {:ok, struct()} | {:error, any()}
@doc @new_ok_doc
@callback new(enumerable :: Enumerable.t(), opts :: keyword()) :: {:ok, struct()} | {:error, any()}
@doc @ensure_type_raise_doc
@callback ensure_type!(struct :: struct()) :: struct()
@callback ensure_type(struct :: struct()) :: {:ok, struct()} | {:error, any()}
@doc @ensure_type_ok_doc
@callback ensure_type(struct :: struct(), opts :: keyword()) :: {:ok, struct()} | {:error, any()}
@callback typed_fields() :: [atom()]
@doc @typed_fields_doc
@callback typed_fields(opts :: keyword()) :: [atom()]
@callback required_fields() :: [atom()]
@doc @required_fields_doc
@callback required_fields(opts :: keyword()) :: [atom()]
@mix_project Application.compile_env(:domo, :mix_project, Mix.Project)
alias Domo.ErrorBuilder
alias Domo.CodeEvaluation
alias Domo.Raises
alias Domo.TypeEnsurerFactory
alias Domo.TypeEnsurerFactory.Error
alias Mix.Tasks.Compile.DomoCompiler, as: DomoMixTask
@doc """
Uses Domo in the current struct's module to add constructor, validation,
and reflection functions.
defmodule Model do
use Domo
defstruct [:first_field, :second_field]
@type t :: %__MODULE__{first_field: atom() | nil, second_field: any() | nil}
# have added:
# new!/1
# new/2
# ensure_type!/1
# ensure_type/2
# typed_fields/1
# required_fields/1
end
`use Domo` can be called only within the struct module having
`t()` type defined because it's used to generate `__MODULE__.TypeEnsurer`
with validation functions for each field in the definition.
See details about `t()` type definition in Elixir
[TypeSpecs](https://hexdocs.pm/elixir/typespecs.html) document.
The macro collects `t()` type definitions for the `:domo_compiler` which
generates `TypeEnsurer` modules during the second pass of the compilation
of the project. Generated validation functions rely on guards appropriate
for the field types.
The generated code of each `TypeEnsurer` module can be found
in `_build/MIX_ENV/domo_generated_code` folder. However, that is for information
purposes only. The following compilation will overwrite all changes there.
The macro adds the following functions to the current module, that are the
facade for the generated `TypeEnsurer` module:
`new!/1`, `new/2`, `ensure_type!/1`, `ensure_type/2`, `typed_fields/1`,
`required_fields/1`.
## Options
#{@using_options}
"""
# credo:disable-for-lines:332
defmacro __using__(opts) do
Raises.raise_use_domo_out_of_module!(__CALLER__)
in_mix_compile? = CodeEvaluation.in_mix_compile?()
config = @mix_project.config()
if in_mix_compile? do
Raises.maybe_raise_absence_of_domo_compiler!(config, __CALLER__)
else
do_test_env_ckeck =
case Application.fetch_env(:domo, :skip_test_env_check) do
{:ok, true} -> false
_ -> true
end
if do_test_env_ckeck and CodeEvaluation.in_mix_test?() do
Raises.raise_cant_build_in_test_environment(__CALLER__.module)
end
# We consider to be in interactive mode
opts = [verbose?: Application.get_env(:domo, :verbose_in_iex, false)]
TypeEnsurerFactory.start_resolve_planner(:in_memory, :in_memory, opts)
end
maybe_build_type_ensurer_after_compile =
unless in_mix_compile? do
quote do
@after_compile {Domo, :_build_in_memory_type_ensurer}
end
end
global_anys =
if global_anys = Application.get_env(:domo, :remote_types_as_any) do
Raises.raise_incorrect_remote_types_as_any_format!(global_anys)
global_anys
end
local_anys =
if local_anys = Keyword.get(opts, :remote_types_as_any) do
Raises.raise_incorrect_remote_types_as_any_format!(local_anys)
Enum.map(local_anys, fn {module, types} -> {Macro.expand_once(module, __CALLER__), types} end)
end
plan_path =
if in_mix_compile? do
DomoMixTask.manifest_path(@mix_project, :plan)
else
:in_memory
end
unless is_nil(global_anys) and is_nil(local_anys) do
TypeEnsurerFactory.collect_types_to_treat_as_any(plan_path, __CALLER__.module, global_anys, local_anys)
end
global_new_func_name = Application.get_env(:domo, :name_of_new_function, :new)
new_ok_fun_name = Keyword.get(opts, :name_of_new_function, global_new_func_name)
new_raise_fun_name =
new_ok_fun_name
|> Atom.to_string()
|> List.wrap()
|> Enum.concat(["!"])
|> Enum.join()
|> String.to_atom()
long_module = TypeEnsurerFactory.module_name_string(__CALLER__.module)
short_module = long_module |> String.split(".") |> List.last()
type_ensurer = TypeEnsurerFactory.type_ensurer(__CALLER__.module)
quote do
Module.register_attribute(__MODULE__, :domo_options, accumulate: false)
Module.put_attribute(__MODULE__, :domo_options, unquote(opts))
Module.register_attribute(__MODULE__, :domo_plan_path, accumulate: false)
Module.put_attribute(__MODULE__, :domo_plan_path, unquote(plan_path))
@compile {:no_warn_undefined, unquote(type_ensurer)}
import Domo, only: [precond: 1]
@doc """
#{unquote(@new_raise_doc)}
## Examples
alias #{unquote(long_module)}
#{unquote(short_module)}.#{unquote(new_raise_fun_name)}(first_field: value1, second_field: value2, ...)
"""
def unquote(new_raise_fun_name)(enumerable \\ []) do
skip_ensurance? =
if CodeEvaluation.in_plan_collection?() do
Domo._plan_struct_integrity_ensurance(__MODULE__, enumerable)
true
else
false
end
struct = struct!(__MODULE__, enumerable)
unless skip_ensurance? do
{errors, t_precondition_error} = Domo._do_validate_fields(unquote(type_ensurer), struct, :pretty_error)
unless Enum.empty?(errors) do
Raises.raise_or_warn_values_should_have_expected_types(unquote(opts), __MODULE__, errors)
end
unless is_nil(t_precondition_error) do
Raises.raise_or_warn_struct_precondition_should_be_true(unquote(opts), t_precondition_error)
end
end
struct
end
@doc """
#{unquote(@new_ok_doc)}
## Examples
alias #{unquote(long_module)}
#{unquote(short_module)}.#{unquote(new_ok_fun_name)}(first_field: value1, second_field: value2, ...)
"""
def unquote(new_ok_fun_name)(enumerable \\ [], opts \\ []) do
skip_ensurance? =
if CodeEvaluation.in_plan_collection?() do
Domo._plan_struct_integrity_ensurance(__MODULE__, enumerable)
true
else
false
end
struct = struct(__MODULE__, enumerable)
if skip_ensurance? do
{:ok, struct}
else
{errors, t_precondition_error} = Domo._do_validate_fields(unquote(type_ensurer), struct, :pretty_error_by_key, opts)
cond do
not Enum.empty?(errors) -> {:error, errors}
not is_nil(t_precondition_error) -> {:error, [t_precondition_error]}
true -> {:ok, struct}
end
end
end
@doc """
#{unquote(@ensure_type_raise_doc)}
## Examples
alias #{unquote(long_module)}
struct = #{unquote(short_module)}.#{unquote(new_raise_fun_name)}(first_field: value1, second_field: value2, ...)
#{unquote(short_module)}.ensure_type!(%{struct | first_field: new_value})
struct
|> Map.put(:first_field, new_value1)
|> Map.put(:second_field, new_value2)
|> #{unquote(short_module)}.ensure_type!()
"""
def ensure_type!(struct) do
%name{} = struct
unless name == __MODULE__ do
Raises.raise_struct_should_be_passed(__MODULE__, instead_of: name)
end
skip_ensurance? =
if CodeEvaluation.in_plan_collection?() do
Domo._plan_struct_integrity_ensurance(__MODULE__, Map.from_struct(struct))
true
else
false
end
unless skip_ensurance? do
{errors, t_precondition_error} = Domo._do_validate_fields(unquote(type_ensurer), struct, :pretty_error)
unless Enum.empty?(errors) do
Raises.raise_or_warn_values_should_have_expected_types(unquote(opts), __MODULE__, errors)
end
unless is_nil(t_precondition_error) do
Raises.raise_or_warn_struct_precondition_should_be_true(unquote(opts), t_precondition_error)
end
end
struct
end
@doc """
#{unquote(@ensure_type_ok_doc)}
Options are the same as for `#{unquote(new_ok_fun_name)}/2`.
## Examples
alias #{unquote(long_module)}
struct = #{unquote(short_module)}.#{unquote(new_raise_fun_name)}(first_field: value1, second_field: value2, ...)
{:ok, _updated_struct} =
#{unquote(short_module)}.ensure_type(%{struct | first_field: new_value})
{:ok, _updated_struct} =
struct
|> Map.put(:first_field, new_value1)
|> Map.put(:second_field, new_value2)
|> #{unquote(short_module)}.ensure_type()
"""
def ensure_type(struct, opts \\ []) do
%name{} = struct
unless name == __MODULE__ do
Raises.raise_struct_should_be_passed(__MODULE__, instead_of: name)
end
skip_ensurance? =
if CodeEvaluation.in_plan_collection?() do
Domo._plan_struct_integrity_ensurance(__MODULE__, Map.from_struct(struct))
true
else
false
end
if skip_ensurance? do
{:ok, struct}
else
Domo._validate_fields_ok(unquote(type_ensurer), struct, opts)
end
end
@doc unquote(@typed_fields_doc)
def typed_fields(opts \\ []) do
field_kind =
cond do
opts[:include_any_typed] && opts[:include_meta] -> :typed_with_meta_with_any
opts[:include_meta] -> :typed_with_meta_no_any
opts[:include_any_typed] -> :typed_no_meta_with_any
true -> :typed_no_meta_no_any
end
unquote(type_ensurer).fields(field_kind)
end
@doc unquote(@required_fields_doc)
def required_fields(opts \\ []) do
field_kind = if opts[:include_meta], do: :required_with_meta, else: :required_no_meta
unquote(type_ensurer).fields(field_kind)
end
@before_compile {Raises, :raise_not_in_a_struct_module!}
@before_compile {Raises, :raise_no_type_t_defined!}
@before_compile {Domo, :_plan_struct_defaults_ensurance}
@after_compile {Domo, :_collect_types_for_domo_compiler}
unquote(maybe_build_type_ensurer_after_compile)
end
end
@doc false
def _plan_struct_defaults_ensurance(env) do
plan_path = Module.get_attribute(env.module, :domo_plan_path)
TypeEnsurerFactory.plan_struct_defaults_ensurance(plan_path, env)
end
@doc false
def _collect_types_for_domo_compiler(env, bytecode) do
plan_path = Module.get_attribute(env.module, :domo_plan_path)
TypeEnsurerFactory.collect_types_for_domo_compiler(plan_path, env, bytecode)
end
@doc false
def _build_in_memory_type_ensurer(env, bytecode) do
verbose? = Application.get_env(:domo, :verbose_in_iex, false)
TypeEnsurerFactory.register_in_memory_types(env.module, bytecode)
# struct's types are collected with separate _collect_types_for_domo_compiler call
TypeEnsurerFactory.maybe_collect_lib_structs_to_treat_as_any_to_existing_plan(:in_memory)
{:ok, plan, preconds} = TypeEnsurerFactory.get_plan_state(:in_memory)
with {:ok, module_filed_types, dependencies_by_module} <- TypeEnsurerFactory.resolve_plan(plan, preconds, verbose?),
TypeEnsurerFactory.build_type_ensurers(module_filed_types, verbose?),
:ok <- TypeEnsurerFactory.ensure_struct_defaults(plan, verbose?) do
{:ok, dependants} = TypeEnsurerFactory.get_dependants(:in_memory, env.module)
unless dependants == [] do
TypeEnsurerFactory.invalidate_type_ensurers(dependants)
Raises.warn_invalidated_type_ensurers(env.module, dependants)
end
TypeEnsurerFactory.register_dependants_from(:in_memory, dependencies_by_module)
TypeEnsurerFactory.clean_plan(:in_memory)
:ok
else
{:error, [%Error{message: {:no_types_registered, _} = error}]} -> Raises.raise_cant_find_type_in_memory(error)
{:error, {:batch_ensurer, _details} = message} -> Raises.raise_incorrect_defaults(message)
end
end
@doc false
def _plan_struct_integrity_ensurance(module, enumerable) do
plan_path = DomoMixTask.manifest_path(@mix_project, :plan)
TypeEnsurerFactory.plan_struct_integrity_ensurance(plan_path, module, enumerable)
end
@doc false
def _validate_fields_ok(type_ensurer, struct, opts) do
{errors, t_precondition_error} = Domo._do_validate_fields(type_ensurer, struct, :pretty_error_by_key, opts)
cond do
not Enum.empty?(errors) -> {:error, errors}
not is_nil(t_precondition_error) -> {:error, [t_precondition_error]}
true -> {:ok, struct}
end
end
def _do_validate_fields(type_ensurer, struct, err_fun, opts \\ []) do
maybe_filter_precond_errors = Keyword.get(opts, :maybe_filter_precond_errors, false)
maybe_bypass_precond_errors = Keyword.get(opts, :maybe_bypass_precond_errors, false)
typed_no_any_fields = type_ensurer.fields(:typed_with_meta_no_any)
errors =
Enum.reduce(typed_no_any_fields, [], fn field, errors ->
field_value = {field, Map.get(struct, field)}
case type_ensurer.ensure_field_type(field_value, opts) do
{:error, _} = error ->
[apply(ErrorBuilder, err_fun, [error, maybe_filter_precond_errors, maybe_bypass_precond_errors]) | errors]
_ ->
errors
end
end)
t_precondition_error =
if Enum.empty?(errors) do
case type_ensurer.t_precondition(struct) do
{:error, _} = error -> apply(ErrorBuilder, err_fun, [error, maybe_filter_precond_errors, maybe_bypass_precond_errors])
:ok -> nil
end
end
{errors, t_precondition_error}
end
@doc """
Defines a precondition function for a field's type or the struct's type.
The `type_fun` argument is one element `[type: fun]` keyword list where
`type` is the name of the type defined with the `@type` attribute
and `fun` is a single argument user-defined precondition function.
The precondition function validates the value of the given type to match
a specific format or to fulfil a set of invariants for the field's type
or struct's type respectfully.
The macro should be called with a type in the same module where the `@type`
definition is located. If that is no fulfilled, f.e., when the previously
defined type has been renamed, the macro raises an `ArgumentError`.
defstruct [id: "I-000", amount: 0, limit: 15]
@type id :: String.t()
precond id: &validate_id/1
defp validate_id(id), do: match?(<<"I-", _::8*3>>, id)
@type t :: %__MODULE__{id: id(), amount: integer(), limit: integer()}
precond t: &validate_invariants/1
defp validate_invariants(s) do
cond do
s.amount >= s.limit ->
{:error, "Amount \#{s.amount} should be less then limit \#{s.limit}."}
true ->
:ok
end
end
`TypeEnsurer` module generated by Domo calls the precondition function with
value of the valid type. Precondition function should return the following
values: `true | false | :ok | {:error, any()}`.
In case of `true` or `:ok` return values `TypeEnsurer` finishes
the validation of the field with ok.
For the `false` return value, the `TypeEnsurer` generates an error message
referencing the failed precondition function. And for the `{:error, message}`
return value, it passes the `message` as one of the errors for the field value.
`message` can be of any shape.
Macro adds the `__precond__/2` function to the current module that routes
a call to the user-defined function. The added function should be called
only by Domo modules.
Attaching a precondition function to the type via this macro can be helpful
to keep the same level of consistency across the domains modelled
with structs sharing the given type.
"""
defmacro precond([{type_name, {fn?, _, _} = fun}] = _type_fun)
when is_atom(type_name) and fn? in [:&, :fn] do
module = __CALLER__.module
unless Module.has_attribute?(module, :domo_precond) do
Module.register_attribute(module, :domo_precond, accumulate: true)
Module.put_attribute(module, :after_compile, {Domo, :_plan_precond_checks})
end
fun_as_string = Macro.to_string(fun) |> Code.format_string!() |> to_string()
precond_name_description = {type_name, fun_as_string}
Module.put_attribute(module, :domo_precond, precond_name_description)
quote do
def __precond__(unquote(type_name), value) do
unquote(fun).(value)
end
end
end
defmacro precond(_arg) do
Raises.raise_precond_arguments()
end
@doc false
def _plan_precond_checks(env, bytecode) do
in_mix_compile? = CodeEvaluation.in_mix_compile?()
if in_mix_compile? do
config = @mix_project.config()
Raises.maybe_raise_absence_of_domo_compiler!(config, env)
end
plan_path =
if in_mix_compile? do
DomoMixTask.manifest_path(@mix_project, :plan)
else
:in_memory
end
TypeEnsurerFactory.plan_precond_checks(plan_path, env, bytecode)
end
@doc """
Checks whether the `TypeEnsurer` module exists for the given struct module.
Structs having `TypeEnsurer` can be validated with `Domo` generated callbacks.
"""
defdelegate has_type_ensurer?(struct_module), to: TypeEnsurerFactory
end