Current section

Files

Jump to
definject lib inject.ex
Raw

lib/inject.ex

defmodule Inject do
@moduledoc """
`definject` transforms function to accecpt a map where we can inject dependent functions.
## definject
use Inject
definject send_welcome_email(user_id) do
%{email: email} = Repo.get(User, user_id)
Email.welcome(email)
|> Mailer.send()
end
becomes
def send_welcome_email(user_id, deps \\\\ %{}) do
%{email: email} = (deps[{Repo, :get, 2}] || &Repo.get/2).(User, user_id)
(deps[{Email, :welcome, 1}] || &Email.welcome/1).(email)
|> (deps[{Mailer, :send, 1}] || &Mailer.send/1).()
end
## mock
Then we can inject mock functions in tests.
test "send_welcome_email" do
Accounts.send_welcome_email(100, %{
{Repo, :get, 2} => fn User, 100 -> %User{email: "mr.jechol@gmail.com"} end,
{Mailer, :send, 1} => fn %Email{to: "mr.jechol@gmail.com", subject: "Welcome"} ->
Process.send(self(), :email_sent)
end
})
assert_receive :email_sent
end
or more simply if you are not interested in arguments passed in mock
test "send_welcome_email with mock/1" do
Accounts.send_welcome_email(
100,
mock(%{
&Repo.get/2 => %User{email: "mr.jechol@gmail.com"},
&Mailer.send/1 => Process.send(self(), :email_sent)
})
)
assert_receive :email_sent
end
### strict: false
`definject` raises if the passed map includes function which is not dependency of the injected function.
You can disable this check by adding strict: false.
Accounts.send_welcome_email(100, %{
{Repo, :get, 2} => fn User, 100 -> %User{email: "mr.jechol@gmail.com"} end,
{Repo, :all, 1} => fn _ -> [%User{email: "mr.jechol@gmail.com"}] end,
:strict => false,
})
"""
@uninjectable [:erlang, Kernel, Macro, Module, Access]
defmacro __using__(_opts) do
quote do
import Inject, only: [definject: 2, mock: 1]
end
end
defmacro definject(head, do: body) do
original =
quote do
def unquote(head), do: unquote(body)
end
if Application.get_env(:definject, :enabled?, Mix.env() == :test) do
{:__block__, [], [original, inject_function(%{head: head, body: body, env: __CALLER__})]}
else
original
end
end
@doc false
def inject_function(%{head: head, body: body, env: %Macro.Env{} = env}) do
injected_head = head_with_deps(%{head: head})
{_, env_modified?} =
Macro.prewalk(body, false, fn ast, env_modified? ->
{ast, env_modified? || modifies_env?(ast)}
end)
if env_modified? do
quote do
raise "Cannot import/require/use inside definject. Move it to module level."
end
else
{injected_body, mfas} =
body
|> Macro.prewalk(fn ast ->
if expandable?(ast) do
Macro.expand(ast, env)
else
ast
end
end)
|> Macro.postwalk([], fn ast, mfas ->
%{ast: ast, mfas: new_mfas} = inject_remote_call(ast)
{ast, new_mfas ++ mfas}
end)
# `quote` with dynamic `context` requires Elixir 1.10+
quote do
def unquote(injected_head) do
Inject.Check.raise_if_uninjectable_deps_injected(deps)
Inject.Check.raise_if_unknown_deps_found(unquote(Macro.escape(mfas)), deps)
unquote(injected_body)
end
end
end
end
defp expandable?({:@, _, _}), do: false
defp expandable?(_), do: true
defp modifies_env?({name, _, _}) when name in [:import, :require, :use], do: true
defp modifies_env?(_), do: false
@doc false
def head_with_deps(%{head: {name, meta, context}}) when not is_list(context) do
# Normalize function head.
# def some do: nil end -> def some(), do: nil end
head_with_deps(%{head: {name, meta, []}})
end
def head_with_deps(%{head: {name, meta, params}}) when is_list(params) do
deps =
quote do
%{} = deps
end
{name, meta, params ++ [deps]}
end
@doc false
def inject_remote_call({{:., _, [remote_mod, name]} = func, _, args})
when remote_mod not in @uninjectable and is_atom(name) and is_list(args) do
arity = Enum.count(args)
capture = {func, [no_parens: true], []}
ast =
quote do
(deps[{unquote(remote_mod), unquote(name), unquote(arity)}] ||
&(unquote(capture) / unquote(arity))).(unquote_splicing(args))
end
%{ast: ast, mfas: [{remote_mod, name, arity}]}
end
def inject_remote_call(ast) do
%{ast: ast, mfas: []}
end
#
defmacro mock({:%{}, _, mocks}) do
mocks =
mocks
|> Enum.map(fn {k, v} ->
{:&, _, [capture]} = k
{:/, _, [mf, a]} = capture
{mf, _, []} = mf
{:., _, [m, f]} = mf
quote do
{{unquote(m), unquote(f), unquote(a)},
unquote(__MODULE__).make_const_function(unquote(a), unquote(v), unquote(__CALLER__))}
end
end)
{:%{}, [], mocks}
end
@doc false
defmacro make_const_function(arity, expr, %Macro.Env{module: context}) do
{:fn, [], [{:->, [], [Macro.generate_arguments(arity, context), expr]}]}
end
end