Current section

Files

Jump to
domo lib domo.ex
Raw

lib/domo.ex

defmodule Domo do
@moduledoc """
Domo is a library to model a business domain with type-safe structs and
composable tagged tuples.
The library aims for two goals:
* to allow only valid states for domain entities modelled with structs
by ensuring that their field values conforms the type definition
* to separate struct type definition and field type constraints
that can be reused among related contexts or applications
f.e. in a micro-service setup
It's a library to declare what kind of values struct fields should have and
to generate run-time struct construction and verification functions.
The `new/1` constructor function generated by Domo ensures that the struct
field value matches Elixir data type or another struct according to the given
type definition. Then it ensures that the field's value is in the valid range
by calling the user-defined precondition function for the value's type.
The library is a practical tool for the compile-time check. It automatically
validates that the default values of the struct conform to its type.
And ensure that instances constructed with `new/1` function to be
a macro argument match their types.
## Rationale
One of the ways to validate that input data is of the model struct's type
is to do it within a constructor function like the following:
defmodule User do
@type divisible_5_integer :: integer()
@type t :: %__MODULE__{
id: divisible_5_integer(),
name: String.t(),
post_address: :not_given | String.t()
}
@enforce_keys [:id, :name]
defstruct [:id, :name, post_address: :not_given]
def new(id: id, name: name, post_address: post_address)
when is_integer(id) and is_binary(name) and
(post_address == :not_given or is_binary(post_address)) do
if rem(id, 5) == 0 do
struct!(__MODULE__, id: id, name: name)
else
raise "User id expected to be divisible by 5"
end
end
end
The code of the constructor function `new/1` written above ensures that
the structure instance would have the expected data. At the same time,
the equally looking code can repeat for almost every entity in the application,
especially when some of the field types are shared across various entities.
It'd be good to generate such constructor function `new/1` automatically
in a declarative way reducing the structure definition length to a minimum.
One of the possible ways to do so is with Domo library as the following:
defmodule User do
use Domo
@type divisible_5_integer :: integer()
precond divisible_5_integer: &(rem(&1, 5) == 0)
@type t :: %__MODULE__{
id: divisible_5_integer(),
name: String.t(),
post_address: :not_given | String.t()
}
@enforce_keys [:id, :name]
defstruct [:id, :name, post_address: :not_given]
end
The struct and type definitions are already in the module.
What Domo adds on top are the constructor function `new/1`,
the `ensure_type!/1` function, and their `new_ok/1`
and `ensure_type_ok/1` versions returning ok-error tuple.
These functions ensure that the given enumerable has keys matching the struct
fields and has the values of the appropriate field types. Additionally,
for the value of the `divisible_5_integer` type, these functions check
if the precondition function defined via the `precond` macro returns true.
If these conditions are not fulfilled, the Domo added functions are raising
the `ArgumentError` exception or returning `{:error, message}` respectively.
The construction with automatic type ensurance of the `User` struct can
be as immediate as that:
User.new(id: 55, name: "John")
%User{id: 55, name: "John", post_address: :not_given}
User.new(id: 55, name: nil, post_address: 3)
** (ArgumentError) the following values should have types defined for fields of the User struct:
* Invalid value 3 for field :post_address of %User{}. Expected the value matching the :not_given | <<_::_*8>> type.
* Invalid value nil for field :name of %User{}. Expected the value matching the <<_::_*8>> type.
User.new(id: 54, name: "John")
** (ArgumentError) the following values should have types defined for fields of the User struct:
* Invalid value 54 for field :id of %User{}. Expected the value matching the integer() type. And a true value
from the precondition function "&(rem(&1, 5) == 0)" defined for User.divisible_5_integer() type.
After the modification of the existing struct its type can be ensured
like the following:
user
|> struct!(name: "John Bongiovi")
|> User.ensure_type!()
%User{id: 55, name: "John Bongiovi", post_address: :not_given}
user
|> struct!(name: :john_bongiovi)
|> User.ensure_type!()
** (ArgumentError) the following values should have types defined for fields of the User struct:
* Invalid value :john_bongiovi for field :name of %User{}. Expected the value matching the <<_::_*8>> type.
Given validations can be especially effective if it is possible to reuse
user-defined types and preconditions for one struct in related structs
like a foreign key in database tables.
Domo enables this fully. That how it works for the value of
`divisible_5_integer` type defined in the `User` module mentioned above
and referenced from the `Account` struct:
defmodule Account do
use Domo
@type t :: %__MODULE__{
id: integer(),
user_id: User.divisible_5_integer(),
ballance: integer()
}
@enforce_keys [:id, :user_id, :ballance]
defstruct @enforce_keys
end
Account.new(id: 1574, user_id: 55, ballance: 20087)
%Account{ballance: 20087, id: 1574, user_id: 55}
Account.new(id: 1574, user_id: 504, ballance: 20087)
** (ArgumentError) the following values should have types defined for fields of the Account struct:
* Invalid value 504 for field :user_id of %Account{}. Expected the value
matching the integer() type. And a true value from the precondition function
"&(rem(&1, 5) == 0)" defined for User.divisible_5_integer() type.
Furthermore, it's possible to extract the common type and its precondition
to a module that can be shared across various applications using Domo, keeping
the integrity of the types appropriately.
The code of `User` and `Account` modules mentioned above can be refactored
for that like the following:
defmodule Identifiers do
import Domo
@type user_id :: integer()
precond user_id: &(rem(&1, 5) == 0)
end
defmodule User do
use Domo
@type t :: %__MODULE__{
id: Identifiers.user_id(),
name: String.t(),
post_address: :not_given | String.t()
}
@enforce_keys [:id, :name]
defstruct [:id, :name, post_address: :not_given]
end
defmodule Account do
use Domo
@type t :: %__MODULE__{
id: integer(),
user_id: Identifiers.user_id(),
ballance: integer()
}
@enforce_keys [:id, :user_id, :ballance]
defstruct @enforce_keys
end
User.new(id: 54, name: "John")
** (ArgumentError) the following values should have types defined for fields of the User struct:
* Invalid value 54 for field :id of %User{}. Expected the value matching the integer() type. And a true value
from the precondition function "&(rem(&1, 5) == 0)" defined for Identifiers.user_id() type.
Account.new(id: 1574, user_id: 504, ballance: 20087)
** (ArgumentError) the following values should have types defined for fields of the Account struct:
* Invalid value 504 for field :user_id of %Account{}. Expected the value
matching the integer() type. And a true value from the precondition function
"&(rem(&1, 5) == 0)" defined for Identifiers.user_id() type.
Domo library plays nicely together with [TypedStruct](https://hexdocs.pm/typed_struct/)
on the module level. That's how the `User` struct can be shortened even more,
having `new/1`, `ensure_type!/1`, `new_ok/1`, and `ensure_type_ok/1` functions
added automatically:
defmodule User do
use Domo
use TypedStruct
typedstruct do
field :id, Identifiers.user_id(), enforce: true
field :name, String.t(), enforce: true
field :post_address, :not_given | String.t(), default: :not_given
end
end
User.new_ok(id: 55, name: "John")
{:ok, %User{id: 55, name: "John", post_address: :not_given}}
User.new_ok(id: 54, name: "John")
{:error, [id: "Invalid value 54 for field :id of %User{}. \\
Expected the value matching the integer() type. And a true value \\
from the precondition function \\"&(rem(&1, 5) == 0)\\" defined \\
for User.divisible_5_integer() type."]}
## How it works
For `MyModule` struct using Domo, the library generates a `MyModule.TypeEnsurer`
module at the compile time. The latter verifies that the given fields matches the
type of `MyModule` and is used by `new/1` constructor and the other Domo
generated functions.
If the field is of the struct type that uses Domo as well, then the ensurance
of the field's value delegates to the `TypeEnsurer` of that struct.
Suppose the user defines the precondition function with the `precond/1` macro
for the type referenced in the struct using Domo. In that case,
the `TypeEnsurer` module calls the user-defined function as the last
verification step.
Domo library uses `:domo_compiler` to generate `TypeEnsurer` modules code. See
the [Setup](#module-setup) section for the compilers configuration.
The generated code can be found in the `_build/ENV/domo_generated_code`
directory. That code is compiled automatically and is there only for
the reader's confidence. Domo cleans the directory on the following compilation,
keeping generated type ensurers code only for recently created
or changed structs.
### Depending types tracking
Suppose the given structure field's type depends on a type defined in
another module. When the latter type or its precondition changes,
Domo recompiles the former module automatically to update its
`TypeEnsurer` to keep type validation correct.
That works in the same way for any number of intermediate modules
that can be between module defining struct and module defining type.
## Setup
### General setup
To use Domo in your project, add this to your `mix.exs` dependencies:
{:domo, "~> #{Mix.Project.config()[:version]}"}
And the following line to the compilers:
compilers: Mix.compilers() ++ [:domo_compiler],
To avoid `mix format` putting extra parentheses around macro calls,
you can add the following import to your `.formatter.exs`:
[
import_deps: [:domo]
]
### Setup for Phoenix hot reload
If you intend to call generated functions of structs using Domo from a Phoenix controller, add the following line to the endpoint's configuration in `config.exs` file:
config :my_app, MyApp.Endpoint,
reloadable_compilers: [:phoenix] ++ Mix.compilers() ++ [:domo_compiler],
Otherwise type changes wouldn't be hot-reloaded by Phoenix.
## Usage
### Define a structure
To describe a structure with field value contracts, `use Domo`, then define
your struct and its type.
defmodule Wonder do
use Domo
@typedoc "A world wonder. Ancient or contemporary."
@enforce_keys [:id]
defstruct [:id, :name]
@type t :: %__MODULE__{id: integer, name: nil | String.t()}
end
The generated structure has `new/1`, `ensure_type!/1` functions
and their non raising `new_ok/1` and `ensure_type_ok/1` versions
defined automatically.
Use these functions to create a new struct's instance and update an existing one.
%{id: 123556}
|> Wonder.new()
|> struct!(name: "Eiffel tower")
|> Wonder.ensure_type!()
%Wonder{id: 123556, name: "Eiffel tower"}
At the run-time, each function checks the values passed in against
the fields types defined within the `t()` type. In case of mismatch, the functions
raise an `ArgumentError` or return `{:error, _}` tuple appropriately.
### Define preconditions for the structure fields values
To automatically verify ranges of values for the whole struct or a concrete
field's type, define a precondition function with the `precond/1` macro
for that type.
defmodule Invoice do
use Domo
@enforce_keys [:id, :subtotal, :tax, :total]
defstruct @enforce_keys
@type id :: String.t()
precond id: &match?(<<"INV", _, _ :: binary>>, &1)
@type t :: %__MODULE__{
id: id(),
subtotal: integer(),
tax: integer(),
total: integer()
}
precond t: &(&1.subtotal + &1.tax == &1.total)
end
A precondition for a field's type generates code that runs faster. In
the example above defining the precondition for the `id` type can be more
performant then making the same check with a precondition for the whole `t` type.
The `new/1`, `ensure_type!/1`, and other Domo generated functions call
precondition function to consider if the value is correct after ensuring
its type. They return an error message if the precondition function
returns false for the given value.
You can reuse user-defined type and its precondition by extracting them to
a shared kernel module like the following:
defmodule SharedKernel do
import Domo
@type invoice_id :: String.t()
precond invoice_id: &match?(<<"INV", _, _ :: binary>>, &1)
end
defmodule Invoice do
use Domo
@enforce_keys [:id, :subtotal, :tax, :total]
defstruct @enforce_keys
@type t :: %__MODULE__{
id: SharedKernel.invoice_id(),
subtotal: integer(),
tax: integer(),
total: integer()
}
precond t: &(&1.subtotal + &1.tax == &1.total)
end
Now it's possible to reuse the `SharedKernel` model across related applications
for value types consistency.
### Define a tag to enrich the field's type
Tags can be used in [sum types](https://thoughtbot.com/blog/better-domain-modeling-in-elixir-with-sum-types)
of your domain model to handle use-cases of the business logic effectively.
To define a tag define a module and its type `t()` as a [tagged tuple](https://erlang.org/doc/getting_started/seq_prog.html#tuples).
That is a tuple of the module name and the associated value.
import Domo.TaggedTuple
defmodule Height do
defmodule Meters, do: @type t :: {__MODULE__, float()}
defmodule Foots, do: @type t :: {__MODULE__, float()}
@type t :: {__MODULE__, Meters.t() | Foots.t()}
end
It's possible add a tag or a tag chain to the associated value.
The tag chain attached to the value is a series of nested tagged tuples where
the value is in the core:
alias Height.{Meters, Foots}
m = {Height, {Meters, 324.0}}
f = {Height, {Foots, 1062.992}}
To extract the value use pattern matching.
{Height, {Meters, 324.0}} == m
def to_string({Height, {Meters, val}}), do: to_string(val) <> " m"
def to_string({Height, {Foots, val}}), do: to_string(val) <> " ft"
### Combine struct, tags, and `---/2` operator
The tag's type can be used to define the summary type for the field:
defmodule Wonder do
use Domo
@typedoc "A world wonder. Ancient or contemporary."
@enforce_keys [:id, :height]
defstruct [:id, name: "", :height]
@type t :: %__MODULE__{id: integer, name: String.t(), height: Height.t()}
end
To remove extra brackets from the tag chain definition or within the pattern matching
it's possible to use the `---/2` operator like the following:
import Domo.TaggedTuple
alias Height.Meters
wonder = Wonder.new(id: 145, name: "Eiffel tower", height: Height --- Meters --- 324.0)
%Wonder{height: {Height, {Height.Meters, 324.0}}, id: 145, name: "Eiffel tower"}
Height --- Meters --- height_m = wonder.height
{Height, {Meters, 324.0}}
### Map syntax
It's still possible to modify a struct directly with `%{... | s }` map syntax
and other standard functions like `put_in/3` skipping the verification.
Please, use the `ensure_type/1` struct's function to validate the struct's
data after such modifications.
### Pipeland
To add a tag or a tag chain to a value in a pipe use `tag/2` macro
and to remove use `untag!/2` macro appropriately.
For instance:
use Domo.TaggedTuple
alias Order.Id
identifier
|> untag!(Id)
|> String.graphemes()
|> Enum.intersperse("_")
|> Enum.join()
|> tag(Id)
## Options
The following options can be passed with `use Domo, [...]`
* `unexpected_type_error_as_warning` - if set to true, prints warning
instead of raising an exception for field type mismatch
in autogenerated functions `new/1` and `ensure_type!/1`.
Default is false.
* `name_of_new_function` - the name of the autogenerated constructor
function added to the module using Domo. The ok function name
is generated automatically from the given one by omitting trailing `!`
if any, and appending `_ok`.
Defaults are `new` and `new_ok` appropriately.
* `remote_types_as_any` - keyword list of types by modules that should
be treated as any(). Value example `ExternalModule: [:t, :name], OtherModule: :t`
Default is nil.
To set option globally add lines into the `config.exs` file like the following:
config :domo, :unexpected_type_error_as_warning, true
config :domo, :name_of_new_function, :new!
## Limitations
The recursive types like `@type t :: :end | {integer, t()}` are not supported.
Because of that `Macro.t()` is not supported.
Parametrized types are not supported. Library returns `{:type_not_found, :key}` error for `@type dict(key, value) :: [{key, value}]` type definition.
`MapSet.t(value)` just checks that the struct is of `MapSet`. Precondition
can be used to verify set values.
Only 4096 combinations of | type are supported. If an `ExternalStruct` has
the `t()` type giving more then 4096 combinations, you can use
`remote_types_as_any: [ExternalStruct: :t]` option to treat it as `any()`,
wrap the type to `@type user_type :: ExternalStruct.t()`
and use `precond user_type: ...` macro to verify the type's value.
Domo doesn't check struct fields default value explicitly; instead,
it fails when one creates a struct with wrong defaults.
Generated submodule with TypedStruct's `:module` option is not supported.
## Migration
To complete the migration to a new version of Domo, please, clean and recompile
the project with `mix clean --deps && mix compile` command.
## Adoption
It's possible to adopt Domo library in the project having user-defined
constructor functions as the following:
1. Add `:domo` dependency to the project, configure compilers as described in
the [setup](#module-setup) section
2. Set the name of the Domo generated constructor function by adding
`config :domo, :name_of_new_function, :constructor_name` option into
the `confix.exs` file, to prevent conflict with original constructor
function names if any
3. Add `use Domo` to existing struct
4. Change the calls to build the struct for Domo generated constructor
function with name set on step 3 and remove original constructor function
5. Repeat for each struct in the project
"""
alias Domo.ErrorBuilder
alias Domo.TypeEnsurerFactory.ResolvePlanner
alias Domo.MixProjectHelper
alias Domo.Raises
alias Mix.Tasks.Compile.DomoCompiler, as: DomoMixTask
@doc false
defmacro __using__(opts) do
Raises.raise_use_domo_out_of_module!(__CALLER__)
Raises.raise_absence_of_domo_compiler!(Mix.Project.config(), opts, __CALLER__)
start_resolve_planner()
global_anys = Application.get_env(:domo, :remote_types_as_any)
local_anys = Keyword.get(opts, :remote_types_as_any)
unless is_nil(global_anys) and is_nil(local_anys) do
collect_types_to_treat_as_any(__CALLER__.module, global_anys, local_anys)
end
global_new_func_name = Application.get_env(:domo, :name_of_new_function, :new)
new_fun_name = Keyword.get(opts, :name_of_new_function, global_new_func_name)
new_ok_fun_name =
new_fun_name
|> Atom.to_string()
|> String.trim("!")
|> List.wrap()
|> Enum.concat(["_ok"])
|> Enum.join()
|> String.to_atom()
quote do
Module.register_attribute(__MODULE__, :domo_options, accumulate: false)
Module.put_attribute(__MODULE__, :domo_options, unquote(opts))
use Domo.TaggedTuple
import Domo, only: [precond: 1]
def unquote(new_fun_name)(enumerable \\ []) do
skip_ensurance? =
if ResolvePlanner.compile_time?() do
Domo._plan_struct_integrity_ensurance(__MODULE__, enumerable)
true
else
false
end
struct = struct!(__MODULE__, enumerable)
unless skip_ensurance? do
Raises.maybe_raise_add_domo_compiler(__MODULE__)
{errors, t_precondition_error} = Domo._collect_errors(__MODULE__, 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
def unquote(new_ok_fun_name)(enumerable \\ []) do
skip_ensurance? =
if ResolvePlanner.compile_time?() do
Domo._plan_struct_integrity_ensurance(__MODULE__, enumerable)
true
else
false
end
struct = struct(__MODULE__, enumerable)
if skip_ensurance? do
{:ok, struct}
else
Raises.maybe_raise_add_domo_compiler(__MODULE__)
{errors, t_precondition_error} = Domo._collect_errors(__MODULE__, struct, :pretty_error_by_key)
cond do
not Enum.empty?(errors) -> {:error, errors}
not is_nil(t_precondition_error) -> {:error, [t_precondition_error]}
true -> {:ok, struct}
end
end
end
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 ResolvePlanner.compile_time?() do
Domo._plan_struct_integrity_ensurance(__MODULE__, Map.from_struct(struct))
true
else
false
end
unless skip_ensurance? do
Raises.maybe_raise_add_domo_compiler(__MODULE__)
{errors, t_precondition_error} = Domo._collect_errors(__MODULE__, 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
def ensure_type_ok(struct) do
%name{} = struct
unless name == __MODULE__ do
Raises.raise_struct_should_be_passed(__MODULE__, instead_of: name)
end
skip_ensurance? =
if ResolvePlanner.compile_time?() do
Domo._plan_struct_integrity_ensurance(__MODULE__, Map.from_struct(struct))
true
else
false
end
if skip_ensurance? do
{:ok, struct}
else
Raises.maybe_raise_add_domo_compiler(__MODULE__)
{errors, t_precondition_error} = Domo._collect_errors(__MODULE__, struct, :pretty_error_by_key)
cond do
not Enum.empty?(errors) -> {:error, errors}
not is_nil(t_precondition_error) -> {:error, [t_precondition_error]}
true -> {:ok, struct}
end
end
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}
end
end
defp start_resolve_planner do
project = MixProjectHelper.global_stub() || Mix.Project
plan_path = DomoMixTask.manifest_path(project, :plan)
preconds_path = DomoMixTask.manifest_path(project, :preconds)
{:ok, _pid} = ResolvePlanner.ensure_started(plan_path, preconds_path)
plan_path
end
defp get_plan_path do
project = MixProjectHelper.global_stub() || Mix.Project
DomoMixTask.manifest_path(project, :plan)
end
defp collect_types_to_treat_as_any(module, global_anys, local_anys) do
plan_path = get_plan_path()
unless is_nil(global_anys) do
global_anys_map = cast_keyword_to_map_of_lists_by_module(global_anys)
ResolvePlanner.keep_global_remote_types_to_treat_as_any(plan_path, global_anys_map)
end
unless is_nil(local_anys) do
local_anys_map = cast_keyword_to_map_of_lists_by_module(local_anys)
ResolvePlanner.keep_remote_types_to_treat_as_any(plan_path, module, local_anys_map)
end
end
defp cast_keyword_to_map_of_lists_by_module(kw_list) do
kw_list
|> Enum.map(fn {key, value} -> {Module.concat(Elixir, key), List.wrap(value)} end)
|> Enum.into(%{})
end
@doc false
def _collect_errors(module, struct, err_fun) do
type_ensurer = Module.concat(module, TypeEnsurer)
errors =
Enum.reduce(Map.from_struct(struct), [], fn key_value, errors ->
case apply(type_ensurer, :ensure_field_type, [key_value]) do
{:error, _} = error ->
[apply(ErrorBuilder, err_fun, [error]) | errors]
_ ->
errors
end
end)
t_precondition_error =
if Enum.empty?(errors) do
case apply(type_ensurer, :t_precondition, [struct]) do
{:error, _} = error -> apply(ErrorBuilder, err_fun, [error])
:ok -> nil
end
end
{errors, t_precondition_error}
end
@doc false
def _collect_types_for_domo_compiler(env, bytecode) do
plan_path = get_plan_path()
:ok = ResolvePlanner.keep_module_environment(plan_path, env.module, env)
{:"::", _, [{:t, _, _}, {:%, _, [_module_name, {:%{}, _, field_type_list}]}]} =
bytecode
|> Code.Typespec.fetch_types()
|> elem(1)
|> Enum.find_value(fn
{:type, {:t, _, _} = t} -> t
_ -> nil
end)
|> Code.Typespec.type_to_quoted()
if Enum.empty?(field_type_list) do
ResolvePlanner.plan_empty_struct(plan_path, env.module)
else
Enum.each(field_type_list, fn {field, quoted_type} ->
:ok ==
ResolvePlanner.plan_types_resolving(
plan_path,
env.module,
field,
quoted_type
)
end)
end
end
@doc false
def _plan_struct_defaults_ensurance(env) do
plan_path = get_plan_path()
struct = Module.get_attribute(env.module, :__struct__) || Module.get_attribute(env.module, :struct)
enforce_keys = Module.get_attribute(env.module, :enforce_keys) || []
keys_to_drop = [:__struct__ | enforce_keys]
defaults =
struct
|> Map.from_struct()
|> Enum.reject(fn {key, _value} -> key in keys_to_drop end)
|> Enum.sort_by(fn {key, _value} -> key end)
:ok ==
ResolvePlanner.plan_struct_defaults_ensurance(
plan_path,
env.module,
defaults,
to_string(env.file),
env.line
)
end
@doc false
def _plan_struct_integrity_ensurance(module, enumerable) do
plan_path = get_plan_path()
{:current_stacktrace, calls} = Process.info(self(), :current_stacktrace)
{_, _, _, file_line} = Enum.find(calls, Enum.at(calls, 3), fn {_, module, _, _} -> module == :__MODULE__ end)
:ok ==
ResolvePlanner.plan_struct_integrity_ensurance(
plan_path,
module,
enumerable,
to_string(file_line[:file]),
file_line[:line]
)
end
@doc """
Macro to define a range precondition function for the type.
The function is called to ensure that the struct field's value range is valid
after ensuring its type just before returning the struct's instance.
Precondition function should return one of the following values:
`true | false | :ok | {:error, any()}`. For false return value library
will generate the error message automatically. And for `{:error, message}` tuple
the `message` will be passed through.
To get the `message` value in the form of `{:error, field: message}` use the
`new_ok/1` or `ensure_type/1`.
When using as:
precond identifier: &match?(<<"AXX-", _, _ :: binary>>, &1)
the macro adds the following function to the module:
def __precond__(:"identifier", value) do
apply(&match?(<<"AXX-", _, _ :: binary>>, &1), [value])
end
Another variant can be:
precond identifier: &(if match?(<<"AXX-", _, _ :: binary>>, &1), do: :ok, else: {:error, "identifier should be like AXX-yyyyyyy"})
"""
defmacro precond([{type_name, {fn?, _, _} = 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)
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
apply(unquote(fun), [value])
end
end
end
defmacro precond(_arg) do
Raises.raise_precond_arguments()
end
@doc false
def _plan_precond_checks(env, bytecode) do
module_type_names =
bytecode
|> Code.Typespec.fetch_types()
|> elem(1)
|> Enum.map(fn {:type, {name, _, _}} -> name end)
module = env.module
precond_name_description = Module.get_attribute(module, :domo_precond)
precond_type_names =
precond_name_description
|> Enum.unzip()
|> elem(0)
missing_type = any_missing_type(precond_type_names, module_type_names)
if missing_type do
Raises.raise_nonexistent_type_for_precond(missing_type)
end
# precond macro can be called via import Domo, so need to start resolve planner
plan_path = start_resolve_planner()
:ok = ResolvePlanner.plan_precond_checks(plan_path, module, precond_name_description)
end
defp any_missing_type(precond_type_names, module_type_names) do
precond_type_names = MapSet.new(precond_type_names)
module_type_names = MapSet.new(module_type_names)
precond_type_names
|> MapSet.difference(module_type_names)
|> MapSet.to_list()
|> List.first()
end
end