Packages

A minimal library for aspect-oriented programming.

Current section

Files

Jump to
exaop lib exaop.ex
Raw

lib/exaop.ex

defmodule Exaop do
@moduledoc """
A minimal library for aspect-oriented programming.
Unlike common AOP patterns, Exaop does not introduce any additional behavior
to existing functions, as it may bring complexity and make the control flow
obscured. Elixir developers prefer explicit over implicit, thus invoking the
cross-cutting behavior by simply calling the plain old function generated by
pointcut definitions is better than using some magic like module attributes
and macros to decorate and weave a function.
## Examples
Here's a simple example of using Exaop:
defmodule Foo do
use Exaop
check :validity
set :compute
@impl true
def check_validity(%{b: b} = _params, _args, _acc) do
if b == 0 do
{:error, :divide_by_zero}
else
:ok
end
end
@impl true
def set_compute(%{a: a, b: b} = _params, _args, acc) do
Map.put(acc, :result, a / b)
end
end
A function `__inject__/2` is generated in the above module `Foo`. When it is
called, the callbacks are triggered in the order defined by your pointcut
definitions.
Throughout the execution of the pointcut callbacks, an accumulator is passed
and updated after running each callback. The execution process may be halted
by a return value of a callback.
If the execution is not halted by any callback, the final accumulator value is
returned by the `__inject__/2` function. Otherwise, the return value of the
callback that terminates the entire execution process is returned.
In the above example, the value of the accumulator is returned if the
`check_validity` is passed:
iex> params = %{a: 1, b: 2}
iex> initial_acc = %{}
iex> Foo.__inject__(params, initial_acc)
%{result: 0.5}
The halted error is returned if the execution is aborted:
iex> params = %{a: 1, b: 0}
iex> initial_acc = %{}
iex> Foo.__inject__(params, initial_acc)
{:error, :divide_by_zero}
## Pointcut definitions
check :validity
set :compute
We've already seen the pointcut definitions in the example before.
`check_validity/3` and `set_compute/3` are the pointcut callback functions
required by these definitions.
Additional arguments can be set:
check :validity, some_option: true
set :compute, {:a, :b}
## Pointcut callbacks
### Naming and arguments
All types of callbacks have the same function signature. Each callback
function following the naming convention in the example, using an underscore
to connect the pointcut type and the following atom as the callback function
name.
Each callback has three arguments and each argument can be of any Elixir term.
The first argument of the callback function is passed from the first argument
of the caller `__inject__/2`. The argument remains unchanged in each callback
during the execution process.
The second argument of the callback function is passed from its pointcut
definition, for example, `set :compute, :my_arg` passes `:my_arg` as the
second argument of its callback function `set_compute/3`.
The third argument is the accumulator. It is initialized as the second
argument of the caller `__inject__/2`. The value of accumulator is updated or
remains the same after each callback execution, depending on the types and
the return values of the callback functions.
### Types and behaviours
Each kind of pointcut has different impacts on the execution process and the
accumulator.
Exaop ships with three pointcut macros:
* `check/3`
* `set/3`
* `preprocess/3`
View documentation of these macros for details.
"""
@type acc :: map | struct
@type args :: term
@type params :: term
defmacro __using__(_opts) do
import Module, only: [register_attribute: 3]
quote do
register_attribute(__MODULE__, :exaop_callbacks, accumulate: true)
@before_compile unquote(__MODULE__)
@behaviour __MODULE__.ExaopBehaviour
import unquote(__MODULE__)
alias __MODULE__.ExaopBehaviour
end
end
defmacro __before_compile__(%{module: module}) do
all_callbacks =
module
|> Module.get_attribute(:exaop_callbacks)
|> Enum.reverse()
local_callbacks =
Enum.filter(all_callbacks, fn
{{^module, _, _}, _, _} -> true
_ -> false
end)
[
compile_callbacks(local_callbacks),
compile_injector(module, all_callbacks)
]
end
@doc """
Allows to decide whether to terminate the entire execution process of the
generated `__inject__/2` function. It does not change the value of the
accumulator.
The execution of the generated function is halted if its callback return
value matches the pattern `{:error, _}`. The execution continues if its
callback returns `:ok`.
"""
defmacro check(target, args \\ nil, opts \\ []) do
operate(:check, target, args, opts)
end
@doc """
Allows to update the value of the accumulator or halt the execution process.
The execution of the generated function is halted if its callback return
value matches the pattern `{:error, _}`. The accumulator is updated to the
wrapped `acc` if its callback return value matches the pattern `{:ok, acc}`.
"""
defmacro preprocess(target, args \\ nil, opts \\ []) do
operate(:preprocess, target, args, opts)
end
@doc """
Allows to update the value of the accumulator, setting the accumulator to the
return value of its callback. It does not halt the execution process.
"""
defmacro set(target, args \\ nil, opts \\ []) do
operate(:set, target, args, opts)
end
@doc false
def mfa(target, args, caller, type, opts) do
wrapped_args = [:params, args, :acc]
target
|> Atom.to_string()
|> match_mfa(target, wrapped_args, caller, type, opts)
end
defp match_mfa("Elixir." <> _, target, args, _caller, type, _opts) do
{target, type, args}
end
defp match_mfa("_" <> name, _target, args, caller, type, _opts) do
function = :"_#{type}_#{name}"
{caller, function, args}
end
defp match_mfa(name, _target, args, caller, type, _opts) do
function = :"#{type}_#{name}"
{caller, function, args}
end
@doc false
def invalid_match_message(module, fun, op, invalid_match) do
valid_ret =
case op do
:check ->
":ok or {:error, _}"
:preprocess ->
"{:ok, _acc} or {:error, _}"
end
"#{module}.#{fun}/3 expects #{valid_ret} as" <>
" return values, got: #{inspect(invalid_match)}"
end
## Helpers
defp compile_callbacks(local_callbacks) do
quote bind_quoted: [
local_callbacks: escape(local_callbacks)
] do
defmodule ExaopBehaviour do
@type acc :: Exaop.acc()
@type args :: Exaop.args()
@type params :: Exaop.params()
Enum.each(local_callbacks, fn {{caller, fun, args}, op, _opts} ->
case op do
:set ->
@callback unquote(fun)(params, args, acc) :: acc
:preprocess ->
@callback unquote(fun)(params, args, acc) :: {:ok, acc} | {:error, term}
:check ->
@callback unquote(fun)(params, args, acc) :: :ok | {:error, term}
end
end)
end
end
end
defp compile_injector(module, all_callbacks) do
quote bind_quoted: [
all_callbacks: escape(all_callbacks),
module: module
] do
@exaop_callbacks_ordered all_callbacks
@doc false
def __inject__(params, initial_acc) do
Enum.reduce_while(@exaop_callbacks_ordered, initial_acc, fn
{{module, fun, [:params, args, :acc]}, op, _opts}, acc ->
case {op, apply(module, fun, [params, args, acc])} do
{:set, new_acc} ->
{:cont, new_acc}
{:preprocess, {:ok, new_acc}} ->
{:cont, new_acc}
{:preprocess, {:error, _} = error} ->
{:halt, error}
{:check, :ok} ->
{:cont, acc}
{:check, {:error, _} = error} ->
{:halt, error}
{op, invalid_match} when op in [:check, :preprocess] ->
msg = invalid_match_message(module, fun, op, invalid_match)
raise ArgumentError, msg
end
end)
end
@doc false
def __exaop_callbacks__ do
@exaop_callbacks_ordered
end
end
end
defp escape(expr) do
Macro.escape(expr, unquote: true)
end
defp operate(op, target, args, opts) do
quote bind_quoted: [
args: args,
injector: __MODULE__,
op: op,
opts: opts,
target: target
] do
mfa = mfa(target, args, __MODULE__, op, opts)
@exaop_callbacks {mfa, op, opts}
end
end
end