Domo behaviour (Domo v1.5.19) View Source
A library to validate values of nested structs with their type spec t()
and associated precondition functions.
Description
The library adds constructor, validation, and reflection functions to a struct module. When called, constructor and validation functions guarantee the following:
- A struct or a group of nested structs conforms to their
t()types. - The struct's data consistently follows the business rules given
by type-associated precondition functions registered via
precondmacro.
If the conditions described above are not met, the constructor and validation functions return an error.
The business rule expressed via the precondition function is type-associated, which affects all structs referencing the appropriate type and using Domo.
In terms of Domain Driven Design, types and associated precondition functions define the invariants relating structs to each other.
use DomoWhen you
use Domo, theDomomodule will define the following functions:
newandnew!to create a valid structensure_typeandensure_type!to validate the existing struct- reflection functions
required_fields,typed_fields, and__t__See the Callbacks section for more details.
Custom constructor function
Sometimes a default value for the struct's field must be generated during the construction.
To reuse the new(!)/1 function's name and keep the generated value validated,
instruct Domo to use another name for its constructor with gen_constructor_name option,
like the following:
defmodule Foo do
use Domo, skip_defaults: true, gen_constructor_name: :_new
defstruct [:id, :token]
@type id :: non_neg_integer()
@type token :: String.t()
precond token: &byte_size(&1) == 8
@type t :: %__MODULE__{id: id(), token: token()}
def new(id) do
_new(id: id, token: random_string(8))
end
def new!(id) do
_new!(id: id, token: random_string(8))
end
defp random_string(length),
do: :crypto.strong_rand_bytes(length) |> Base.encode64() |> binary_part(0, length)
endFoo.new!(15245)%Foo{id: 15245, token: "e8K9wP0e"}Compile-time and Run-time validations
At the project's compile-time, Domo performs the following checks:
It automatically validates that the default values given with
defstruct/1conform to the struct's type and fulfil preconditions (can be disabled, see__using__/1for more details). You can turn off this behaviour by specifyingskip_defaultsoption; see Configuration section for details.It ensures that the struct using Domo built with
new(!)/1function to be a default argument for a function or a default value for a struct's field matches its type and preconditions.
At run-time, Domo validates structs matching their t() types.
Domo compiles TypeEnsurer module from struct's t() type to do all kinds
of validations. There is a generated function with pattern matchings
and guards for each struct's field. Constructor and validation functions
of the struct delegate the work to the appropriate TypeEnsurer module.
After the compilation, the flow of control of the nested StructA validation
can look like the following:
+--------------------+
| PurchaseOrder | +---------------------------+
| | | PurchaseOrder.TypeEnsurer |
| new(!)/1 ----------|--_ | |
| ensure_type(!)/1 --|-----|-> ensure_field_type/1 |
+--------------------+ | private functions |
+----|----------------------+
|
+-----------------------+ |
| LineItem | | +-------------------------+
| | | | LineItem.TypeEnsurer |
| new(!)/1 | | | |
| ensure_type(!)/1 | +--|-> ensure_field_type/1 |
+-----------------------+ | private functions |
+-------------------------+In interactive mode (iex / livebook) Domo generates TypeEnsurer module
dynamically as the last step of struct's module definition.
In mix compile mode Domo generates all TypeEnsurer modules after elixir compiler
finishes its job. The generated code can be found
in _build/MIX_ENV/lib/_your_project_/.mix/domo_generated_code folder. However, that is for information
purposes only. The following compilation will overwrite all changes there.
Depending types tracking
Let's suppose a 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 the type validation up to date.
Domo tracks type-depending modules and touches appropriate files during compilation.
That works for any number of intermediate modules between the module defining the struct's field and the module defining the field's final type.
Integration with Ecto
Ecto schema changeset can be automatically validated to conform to t() type
and associated preconditions. Then the changeset function can be shortened
like the following:
defmodule Customer do
use Ecto.Schema
use Domo, skip_defaults: true
import Ecto.Changeset
import Domo.Changeset
schema "customers" do
field :first_name, :string
field :last_name, :string
field :birth_date, :date
timestamps()
end
@type t :: %__MODULE__{
first_name: String.t(),
last_name: String.t(),
birth_date: Date.t()
}
def changeset(changeset, attrs) do
changeset
|> cast(attrs, __schema__(:fields))
# Domo.Changeset defines validate_type/1 function.
|> validate_type()
end
endThe Domo validation comes with the price of about 1.5x times slower than
the equivalent Ecto's validate_N functions.
See detailed example is in the example_avialia project.
Integration with libraries generating t() type for a struct
Domo is compatible with most libraries that generate t() type for a struct
and an Ecto schema, f.e. typed_struct
and typed_ecto_schema respectfully.
Just use Domo in the module, and that's it.
An example is in the example_typed_integrations project.
TypedStruct's submodule generation with :module option currently is not supported.
Installation
To use Domo in a project, add the following line to mix.exs dependencies:
{:domo, "~> 1.5"}And the following line to the project's mix.exs file:
compilers: [:domo_compiler] ++ Mix.compilers()To exclude the generated TypeEnsurer modules from mix test --coverage
add the following line, that works since Elixir v1.13, to the project's mix.exs:
test_coverage: [ignore_modules: [~r/\.TypeEnsurer$/]]To avoid mix format putting extra parentheses around precond/1 macro call,
add the following import to the .formatter.exs:
[
import_deps: [:domo]
]Phoenix hot-reload
To enable Phoenix hot-reload for struct's type ensurers built by Domo,
update the compilers in the mix.exs file like the following:
compilers: [:domo_compiler] ++ Mix.compilers() ++ [:domo_phoenix_hot_reload]And add the following line to the endpoint's configuration in the config.exs file:
config :my_app, MyApp.Endpoint,
reloadable_compilers: [:phoenix, :domo_compiler] ++ Mix.compilers() ++ [:domo_phoenix_hot_reload]Umbrella application
Add the Domo dependency and compilers config as mentioned in the section above
to the mix.exs file for each app using Domo.
You may add the same domo compiler config line to the app itself and to the root
umbrella's mix.exs to enable recompile command to work correctly
for iex -S mix run in the root.
If you have the Phoenix hot-reload configured for one of the web apps in umbrella then
the :domo_phoenix_hot_reload compiler should be added to all dependency apps
used by the given web one.
Configuration
The options listed below can be set globally in the configuration
with config :domo, option: value. The value given
with use Domo, option: value overrides the global setting.
skip_defaults- if set totrue, disables the validation of default values given withdefstruct/1to conform to thet()type at compile time. Default isfalse.gen_constructor_name- the name of the constructor function added to the module. The raising error function name is generated automatically from the given one by adding trailing!. Defaults arenewandnew!appropriately.unexpected_type_error_as_warning- if set totrue, prints warning instead of throwing an error for field type mismatch in the raising functions. Default isfalse.remote_types_as_any- keyword list of type lists by modules that should be treated asany(). F.e.[{ExternalModule, [:t, :name]}, {OtherModule, :t}]Default isnil.
The following options can be set only globally in the configuration
with config :domo, option: value.
print_types_as_any_list- if set tofalsedisables the Domo will treat the following types as any() message which Domo outputs to the console at the compile time. Default istrue.
Run the Application.put_env(:domo, :verbose_in_iex, true) to enable verbose
messages from domo in Interactive Elixir console.
Limitations
Parametrized types are not supported because it adds lots of complexity.
Library returns {:type_not_found, :key} error
for @type dict(key, value) :: [{key, value}] definition.
Library returns error for type referencing parametrized type like
@type field :: container(integer()).
Primitive types referencing themselves are not supported.
Library returns an error for @type leaf :: leaf | nil definition.
On the other hand, structs referencing themselves are supported.
The library will build TypeEnsurer for the following definition
@type t :: %__MODULE__{leaf: t | nil} and validate.
Performance 🐢
Compilation-time
Library affects the project's full recompilation time almost insignificantly.
The compilation times for the business application with 38 structs (8 fields each on average) having 158 modules in total are the following:
Mode Average (by 3 measurements) Deviation
No Domo 14.826s 11.92
With Domo 15.711s 7.34
Comparison:
No Domo 14.826s
With Domo 15.711s - 1.06x slowerRun-time
The library ensures the correctness of data types at run-time, which comes with the computation price.
One of the standard tasks is translating the map with string values received as a form into a validated nested struct.
For benchmark, we use Album struct having many Track structs.
When comparing Domo generated new!/1 constructor function and Ecto changeset
using a set of validate_.../2 functions internally, both have equivalent execution
times. However, in the former case, the memory consumption is about 25% bigger.
In the case of Ecto changeset that uses Domo.Changeset.validate_type/1 function
the computation takes about 1.5x times longer.
One of the benchmark results is shown below. The benchmark project is
/benchmark_ecto_domo folder. You can run it with mix benchmark command.
Operating System: macOS
CPU Information: Intel(R) Core(TM) i7-4870HQ CPU @ 2.50GHz
Number of Available Cores: 8
Available memory: 16 GB
Elixir 1.11.0
Erlang 24.3.3
Benchmark suite executing with the following configuration:
warmup: 2 s
time: 8 s
memory time: 2 s
parallel: 1
inputs: none specified
Estimated total run time: 36 s
Benchmarking Domo Album.new!/1...
Benchmarking Domo.Changeset validate_type/1...
Benchmarking Ecto.Changeset validate_.../1...
Name ips average deviation median 99th %
Domo Album.new!/1 5.31 188.25 ms ±1.79% 188.28 ms 196.57 ms
Ecto.Changeset validate_.../1 5.21 191.85 ms ±1.94% 191.00 ms 202.22 ms
Domo.Changeset validate_type/1 3.76 266.19 ms ±1.20% 266.58 ms 271.01 ms
Comparison:
Domo Album.new!/1 5.31
Ecto.Changeset validate_.../1 5.21 - 1.02x slower +3.59 ms
Domo.Changeset validate_type/1 3.76 - 1.41x slower +77.93 ms
Memory usage statistics:
Name Memory usage
Domo Album.new!/1 245.73 MB
Ecto.Changeset validate_.../1 186.59 MB - 0.76x memory usage -59.13956 MB
Domo.Changeset validate_type/1 238.69 MB - 0.97x memory usage -7.04444 MB
**All measurements for memory usage were the same**Migration
To migrate 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 named new/1 by refactoring to the Domo generated one.
Here's how:
- Add
:domodependency to the project, configure compilers as described in the installation section - Set the name of the Domo generated constructor function by adding
config :domo, :gen_constructor_name, :_newoption into theconfix.exsfile, to prevent conflict with user-defined constructor function name - Add
use Domoto existing struct, f.e.FirstStruct - Change all struct building calls to be done with Domo generated function with
the name set on step 3 f.e.
FistStruct._new(%{...}) - Repeat for each struct in the project
- Remove original
new/1if it's not needed anymore and rename_newtonewin the whole project - Remove
config :domo, :gen_constructor_nameconfiguration because Domo generates constructor wihtnewname by default.
Link to this section Summary
Callbacks
Returns the struct's type spec t() description for debugging purposes.
Ensures that struct conforms to its t() type and all preconditions
are fulfilled.
Ensures that struct conforms to its t() type and all preconditions
are fulfilled.
Creates a struct validating type conformance and preconditions.
Creates a struct validating type conformance and preconditions.
Returns the list of struct's fields having type others then nil or any().
Returns the list of struct's fields defined with explicit types in its t() type spec.
Functions
Uses Domo in the current struct's module to add constructor, validation, and reflection functions.
Checks whether the TypeEnsurer module exists for the given struct module.
Defines a precondition function for a field's type or the struct's type.
Link to this section Callbacks
Specs
__t__() :: binary()
Returns the struct's type spec t() description for debugging purposes.
Domo compiled validation functions for the given struct based on the described type spec.
Specs
Specs
Ensures that struct conforms to its t() type and all preconditions
are fulfilled.
Returns struct when it's valid in the shape of {:ok, struct}.
Otherwise returns the error in the shape of {:error, message_by_field}.
Takes the same options as new/2.
Useful for struct validation when its fields changed with map syntax
or with Map module functions.
Specs
Ensures that struct conforms to its t() type and all preconditions
are fulfilled.
Returns struct when it's valid. Raises an ArgumentError otherwise.
Useful for struct validation when its fields changed with map syntax
or with Map module functions.
Specs
Specs
new(enumerable :: Enumerable.t()) :: {:ok, struct()} | {:error, any()}
Specs
new(enumerable :: Enumerable.t(), opts :: keyword()) :: {:ok, struct()} | {:error, any()}
Creates a struct validating type conformance and preconditions.
The argument is any Enumerable that emits two-element tuples
(key-value pairs) during enumeration.
Returns the instance of the struct built from the given enumerable
in the shape of {:ok, struct_value}. Does so only if struct's
field values conform to its t() type and all field's type and struct's
type precondition functions return ok.
If conditions described above are not fulfilled, the function
returns an appropriate error in the shape of {:error, message_by_field}.
message_by_field is a keyword list where the key is the name of
the field and value is the string with the error message.
Keys in the enumerable that don't exist in the struct
are automatically discarded.
Options
maybe_filter_precond_errors- when set totrue, the values inmessage_by_fieldinstead of string become a list of error messages from precondition functions. If there are no error messages from precondition functions for a field's type, then all errors are returned unfiltered. Helpful in taking one of the custom errors after executing precondition functions in a deeply nested type to communicate back to the user. F.e. when the field's type is another struct. Default isfalse.
Specs
new!() :: struct()
Specs
new!(enumerable :: Enumerable.t()) :: struct()
Creates a struct validating type conformance and preconditions.
The argument is any Enumerable that emits two-element tuples
(key-value pairs) during enumeration.
Returns the instance of the struct built from the given enumerable.
Does so only if struct's field values conform to its t() type
and all field's type and struct's type precondition functions return ok.
Raises an ArgumentError if conditions described above are not fulfilled.
This function will check if every given key-value belongs to the struct
and raise KeyError otherwise.
Specs
required_fields() :: [atom()]
Specs
Returns the list of struct's fields having type others then nil or any().
Does not return meta fields with __underscored__ names.
Useful for validation of the required fields for emptiness.
F.e. with validate_required/2 call in the Ecto changeset.
Options
:include_meta- when set totrue, adds fields with__underscored__names to the return list. Default isfalse.
Specs
typed_fields() :: [atom()]
Specs
Returns the list of struct's fields defined with explicit types in its t() type spec.
Does not return meta fields with __underscored__ names and fields
having any() type by default.
Includes fields that have nil type into the return list.
Options
:include_any_typed- when set totrue, adds fields withany()type to the return list. Default isfalse.:include_meta- when set totrue, adds fields with__underscored__names to the return list. Default isfalse.
Link to this section Functions
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
enduse 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 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
The options listed below can be set globally in the configuration
with config :domo, option: value. The value given
with use Domo, option: value overrides the global setting.
skip_defaults- if set totrue, disables the validation of default values given withdefstruct/1to conform to thet()type at compile time. Default isfalse.gen_constructor_name- the name of the constructor function added to the module. The raising error function name is generated automatically from the given one by adding trailing!. Defaults arenewandnew!appropriately.unexpected_type_error_as_warning- if set totrue, prints warning instead of throwing an error for field type mismatch in the raising functions. Default isfalse.remote_types_as_any- keyword list of type lists by modules that should be treated asany(). F.e.[{ExternalModule, [:t, :name]}, {OtherModule, :t}]Default isnil.
The following options can be set only globally in the configuration
with config :domo, option: value.
print_types_as_any_list- if set tofalsedisables the Domo will treat the following types as any() message which Domo outputs to the console at the compile time. Default istrue.
Run the Application.put_env(:domo, :verbose_in_iex, true) to enable verbose
messages from domo in Interactive Elixir console.
Checks whether the TypeEnsurer module exists for the given struct module.
Structs having TypeEnsurer can be validated with Domo generated callbacks.
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
endTypeEnsurer 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.