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