defmodule DI do @moduledoc """ Dependency injection ## Usage Include DI in the module that requires dependency injection with `use DI`. Any place in that module that might need a dependency injected can then use `di()` to allow another module to be injected. The module passed to `di/1` will be used if another module isn't injected. ```elixir defmodule MyInterface do use DI def some_command, do: di(__MODULE__).some_command end ``` ### Configuration DI can be configured in the project's `config.exs`. **Opts** - `compile` - (false) - Sets the mappings at compile time and doesn't start \ the process that allows them to be modified at runtime. \ This method is more secure and more performant. \ Compiling is intended for production and runtime is \ intended for unit tests. - `mappings` - `[]` - A two element tuple of the modules to map from and to: \ `{from, to}` **Example** ```elixir config :, di: %{ compile: true, mappings: [ {OriginalModule, InjectedModule}, ] } ``` ### Runtime Dependencies can be injected at runtime with `inject/2`. This is intended for unit testing, but not necessarily limited to it. Runtime mappings will be less performant compared to compiled mappings, as each lookup goes through a read-optimized ETS table. ```elixir DI.inject(OriginalModule, InjectedModule) ``` Modules can also be defined directly in a block, which can be helpful if they are only needed for certain tests. ```elixir DI.inject(Port, quote do def open(_name, _opts), do: self() def close(_port), do: :ok def command(_port, _data), do: :ok end) ``` """ defmacro __using__(_) do quote do @doc false defdelegate di(module), to: DI end end @compile? !!Application.compile_env(:resolve, :di, [])[:compile] @mappings \ Application.compile_env(:resolve, :di, %{}) |> Map.get(:mappings, []) |> Enum.into(%{}) @doc """ Flag a module as eligible for dependency injection. Defaults to `module` unless a new dependency is injected in its place. """ @spec di(module :: module) :: module def di(module), do: di(module, @compile?) defp di(module, _compile? = true) do @mappings[module] || module end defp di(module, _compile? = false) do ensure_ets_is_running() case :ets.lookup(:di, module) do [] -> @mappings[module] || module [{_, injected_module}] -> injected_module end end @doc """ Inject a module in place of another one. """ @spec inject(target_module :: module, injected_module :: module) :: any def inject(target_module, injected_module) when is_atom(injected_module) do ensure_ets_is_running() :ets.insert(:di, {target_module, injected_module}) :ok end def inject(target_module, module_body) do unique_number = System.unique_integer([:positive]) {:module, injected_module, _, _} = Module.create(:"Mock#{unique_number}", module_body, Macro.Env.location(__ENV__)) inject(target_module, injected_module) end @doc """ Revert this dependency to the original module. This function is idempotent and will not fail if DI already points to the original module. """ @spec revert(module :: module) :: any def revert(module) do ensure_ets_is_running() :ets.delete(:di, module) :ok end defp ensure_ets_is_running do case :ets.whereis(:di) do :undefined -> :ets.new(:di, [:public, :named_table, read_concurrency: true]) table_id -> table_id end end end