Domo behaviour (Domo v1.3.4) View Source

A library to ensure the consistency of structs modelling a business domain via their t() types and associated precondition functions.

Used in a struct's module, the library adds constructor, validation, and reflection functions. Constructor and validation functions guarantee the following at call time:

  • A complex struct conforms to its t() type.
  • Structs are validated to be consistent to follow given business rules by precondition functions associated with struct types.

If the conditions described above are not met, the constructor and validation functions return an error.

Because precondition function associates with type the validation can be shared across all structs referencing the type.

In terms of Domain Driven Design the invariants relating structs to each other can be defined with types and associated precondition functions.

Let's say that we have a PurchaseOrder and LineItem structs with relating invariant that is the sum of line item amounts should be less then order's approved limit. That can be expressed like the following:

defmodule PurchaseOrder do
  use Domo

  defstruct [id: 1000, approved_limit: 200, items: []]

  @type id :: non_neg_integer()
  precond id: &(1000 <= &1 and &1 <= 5000)

  @type t :: %__MODULE__{
    id: id(),
    approved_limit: pos_integer(),
    items: [LineItem.t()]
  }
  precond t: &validate_invariants/1

  defp validate_invariants(po) do
    cond do
      po.items |> Enum.map(& &1.amount) |> Enum.sum() > po.approved_limit ->
        {:error, "Sum of line item amounts should be <= to approved limit"}

      true ->
        :ok
    end
  end
end

defmodule LineItem do
  use Domo

  defstruct [amount: 0]

  @type t :: %__MODULE__{amount: non_neg_integer()}
end

Then PurchaseOrder struct can be constructed consistently like that:

iex> {:ok, po} = PurchaseOrder.new_ok()
{:ok, %PurchaseOrder{approved_limit: 200, id: 1000, items: []}}

iex> PurchaseOrder.new_ok(id: 500, approved_limit: 0)
{:error,
 [
   id: "Invalid value 500 for field :id of %PurchaseOrder{}. Expected the 
   value matching the non_neg_integer() type. And a true value from 
   the precondition function \"&(1000 <= &1 and &1 <= 5000)\" 
   defined for PurchaseOrder.id() type.",
   approved_limit: "Invalid value 0 for field :approved_limit of %PurchaseOrder{}. 
   Expected the value matching the pos_integer() type."
 ]}

iex> updated_po = %{po | items: [LineItem.new!(amount: 150), LineItem.new!(amount: 100)]}
%PurchaseOrder{
  approved_limit: 200,
  id: 1000,
  items: [%LineItem{amount: 150}, %LineItem{amount: 100}]
}

iex> PurchaseOrder.ensure_type_ok(updated_po)
{:error, [t: "Sum of line item amounts should be <= to approved limit"]}

iex> updated_po = %{po | items: [LineItem.new!(amount: 150)]}
%PurchaseOrder{approved_limit: 200, id: 1000, items: [%LineItem{amount: 150}]}

iex> PurchaseOrder.ensure_type_ok(updated_po)
{:ok, %PurchaseOrder{approved_limit: 200, id: 1000, items: [%LineItem{amount: 150}]}}

See the Callbacks section for more details about functions added to the struct.

Compile-time and Run-time validations

At the project's compile-time, Domo can perform the following checks:

  • It automatically validates that the default values given with defstruct/1 conform to struct's type and fulfill preconditions.

  • It ensures that the struct using Domo built with new!/1 function to be a function's default argument or a struct field's default value matches its type and preconditions.

Domo validates struct type conformance with appropriate TypeEnsurer modules built during the project's compilation at the application's run-time. These modules rely on guards and pattern matchings. See __using__/1 for more details.

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 in current state.

That works similarly for any number of intermediate modules between module defining the struct's field and module defining the field's final type.

Setup

To use Domo in a project, add the following line to mix.exs dependencies:

{:domo, "~> 1.2.0"}

And the following line to the compilers:

compilers: Mix.compilers() ++ [:domo_compiler],

To avoid mix format putting extra parentheses around precond/1 macro call, add the following import to the .formatter.exs:

[
  import_deps: [:domo]
]

Usage with Phoenix hot reload

To call functions added by Domo from a Phoenix controller, add the following line to the endpoint's configuration in the 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 with Ecto

Ecto schema changeset can be automatically validated to conform to t() type and fulfil associated preconditions.

See Domo.Changeset module documentation for details.

See the example app using Domo to validate Ecto changesets in the /example_avialia folder of this repository.

Usage with libraries generating t() type for a struct

Domo is compatible with most libraries that generate t() type for a struct or an Ecto schema. Just use Domo in the module, and that's it.

An advanced example is in the /example_typed_integrations folder of this repository.

Link to this section Summary

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.

Callbacks

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 its t() type.

Link to this section Functions

Link to this macro

__using__(opts)

View Source (macro)

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_ok/2
  # ensure_type!/1
  # ensure_type_ok/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 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_ok/2, ensure_type!/1, ensure_type_ok/2, typed_fields/1, required_fields/1.

Options

  • ensure_struct_defaults - if set to false, disables the validation of default values given with defstruct/1 to conform to the t() type at compile time. Default is true.

  • name_of_new_function - the name of the constructor function added to the module. 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.

  • unexpected_type_error_as_warning - if set to true, prints warning instead of throwing an error for field type mismatch in the raising functions. Default is false.

  • remote_types_as_any - keyword list of type lists by modules that should be treated as any(). F.e. [{ExternalModule, [:t, :name]}, {OtherModule, :t}] Default is nil.

The option value given to the macro overrides one set globally in the configuration with config :domo, option: value.

Link to this function

has_type_ensurer?(struct_module)

View Source

Checks whether the TypeEnsurer module exists for the given struct module.

Structs having TypeEnsurer can be validated with Domo generated callbacks.

Link to this macro

precond(type_fun)

View Source (macro)

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.

Link to this section Callbacks

Specs

ensure_type!(struct :: struct()) :: struct()

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

ensure_type_ok(struct :: struct()) :: {:ok, struct()} | {:error, any()}
Link to this callback

ensure_type_ok(struct, opts)

View Source

Specs

ensure_type_ok(struct :: struct(), opts :: keyword()) ::
  {:ok, struct()} | {:error, any()}

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}.

Useful for struct validation when its fields changed with map syntax or with Map module functions.

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

new_ok() :: {:ok, struct()} | {:error, any()}

Specs

new_ok(enumerable :: Enumerable.t()) :: {:ok, struct()} | {:error, any()}
Link to this callback

new_ok(enumerable, opts)

View Source

Specs

new_ok(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 to true, the values in message_by_field instead 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 is false.

Specs

required_fields() :: [atom()]

Specs

required_fields(opts :: keyword()) :: [atom()]

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 to true, adds fields with __underscored__ names to the return list. Default is false.

Specs

typed_fields() :: [atom()]

Specs

typed_fields(opts :: keyword()) :: [atom()]

Returns the list of struct's fields defined with its t() type.

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 to true, adds fields with any() type to the return list. Default is false.

  • :include_meta - when set to true, adds fields with __underscored__ names to the return list. Default is false.