lib/nebulex/hook.ex

if Code.ensure_loaded?(Decorator.Define) do
  defmodule Nebulex.Hook do
    @moduledoc """
    Pre/Post Hooks

    Since `v2.0.0`, pre/post hooks are not supported and/or handled by `Nebulex`
    itself. Hooks feature is not a common use-case and also it is something that
    can be be easily implemented on top of the Cache at the application level.

    Nevertheless, to keep backward compatibility somehow, `Nebulex` provides the
    next decorators for implementing pre/post hooks very easily.

    ## `before` decorator

    The `before` decorator is declared for performing a hook action or callback
    before the annotated function is executed.

        @decorate before(fn %Nebulex.Hook{} = hook -> inspect(hook) end)
        def some_fun(var) do
          # logic ...
        end

    ## `after_return` decorator

    The `after_return` decorator is declared for performing a hook action or
    callback after the annotated function is executed and its return is passed
    through the `return:` attribute.

        @decorate after_return(&inspect(&1.return))
        def some_fun(var) do
          # logic ...
        end

    ## `around` decorator

    The final kind of hook is `around` decorator. The `around` decorator runs
    "around" the annotated function execution. It has the opportunity to do
    work both **before** and **after** the function executes. This means the
    given hook function is invoked twice, before and after the code-block is
    evaluated.

        @decorate around(&inspect(&1.step))
        def some_fun(var) do
          # logic ...
        end

    ## Putting all together

    Suppose we want to track all cache calls (before and after they are called)
    by logging them (including the execution time). In this case, we need to
    provide a pre/post hook to log these calls.

    First of all, we have to create a module implementing the hook function:

        defmodule MyApp.Tracker do
          use GenServer

          alias Nebulex.Hook

          require Logger

          @actions [:get, :put]

          ## API

          def start_link(opts \\\\ []) do
            GenServer.start_link(__MODULE__, opts, name: __MODULE__)
          end

          def track(%Hook{step: :before, name: name}) when name in @actions do
            System.system_time(:microsecond)
          end

          def track(%Hook{step: :after_return, name: name} = event) when name in @actions do
            GenServer.cast(__MODULE__, {:track, event})
          end

          def track(hook), do: hook

          ## GenServer Callbacks

          @impl true
          def init(_opts) do
            {:ok, %{}}
          end

          @impl true
          def handle_cast({:track, %Hook{acc: start} = hook}, state) do
            diff = System.system_time(:microsecond) - start
            Logger.info("#=> #\{hook.module}.#\{hook.name}/#\{hook.arity}, Duration: #\{diff}")
            {:noreply, state}
          end
        end

    And then, in the Cache:

        defmodule MyApp.Cache do
          use Nebulex.Hook
          @decorate_all around(&MyApp.Tracker.track/1)

          use Nebulex.Cache,
            otp_app: :my_app,
            adapter: Nebulex.Adapters.Local
        end

    Try it out:

        iex> MyApp.Cache.put 1, 1
        10:19:47.736 [info] Elixir.MyApp.Cache.put/3, Duration: 27
        iex> MyApp.Cache.get 1
        10:20:14.941 [info] Elixir.MyApp.Cache.get/2, Duration: 11

    """

    use Decorator.Define, before: 1, after_return: 1, around: 1

    @enforce_keys [:step, :module, :name, :arity]
    defstruct [:step, :module, :name, :arity, :return, :acc]

    @type t :: %__MODULE__{
            step: :before | :after_return,
            module: Nebulex.Cache.t(),
            name: atom,
            arity: non_neg_integer,
            return: term,
            acc: term
          }

    @type hook_fun :: (t -> term)

    alias Nebulex.Hook

    @doc """
    Before decorator.

    Intercepts any call to the annotated function and calls the given `fun`
    before the logic is executed.

    ## Example

        defmodule MyApp.Example do
          use Nebulex.Hook

          @decorate before(&inspect(&1))
          def some_fun(var) do
            # logic ...
          end
        end

    """
    @spec before(hook_fun, term, map) :: term
    def before(fun, block, context) do
      with_hook([:before], fun, block, context)
    end

    @doc """
    After-return decorator.

    Intercepts any call to the annotated function and calls the given `fun`
    after the logic is executed, and the returned result is passed through
    the `return:` attribute.

    ## Example

        defmodule MyApp.Example do
          use Nebulex.Hook

          @decorate after_return(&inspect(&1))
          def some_fun(var) do
            # logic ...
          end
        end

    """
    @spec after_return(hook_fun, term, map) :: term
    def after_return(fun, block, context) do
      with_hook([:after_return], fun, block, context)
    end

    @doc """
    Around decorator.

    Intercepts any call to the annotated function and calls the given `fun`
    before and after the logic is executed. The result of the first call to
    the hook function is passed through the `acc:` attribute, so it can be
    used in the next call (after return). Finally, as the `after_return`
    decorator, the returned code-block evaluation is passed through the
    `return:` attribute.

    ## Example

        defmodule MyApp.Profiling do
          alias Nebulex.Hook

          def prof(%Hook{step: :before}) do
            System.system_time(:microsecond)
          end

          def prof(%Hook{step: :after_return, acc: start} = hook) do
            :telemetry.execute(
              [:my_app, :profiling],
              %{duration: System.system_time(:microsecond) - start},
              %{module: hook.module, name: hook.name}
            )
          end
        end

        defmodule MyApp.Example do
          use Nebulex.Hook

          @decorate around(&MyApp.Profiling.prof/1)
          def some_fun(var) do
            # logic ...
          end
        end

    """
    @spec around(hook_fun, term, map) :: term
    def around(fun, block, context) do
      with_hook([:before, :after_return], fun, block, context)
    end

    defp with_hook(hooks, fun, block, context) do
      quote do
        hooks = unquote(hooks)
        fun = unquote(fun)

        hook = %Nebulex.Hook{
          step: :before,
          module: unquote(context.module),
          name: unquote(context.name),
          arity: unquote(context.arity)
        }

        # eval before
        acc =
          if :before in hooks do
            Hook.eval_hook(:before, fun, hook)
          end

        # eval code-block
        return = unquote(block)

        # eval after_return
        if :after_return in hooks do
          Hook.eval_hook(
            :after_return,
            fun,
            %{hook | step: :after_return, return: return, acc: acc}
          )
        end

        return
      end
    end

    @doc """
    This function is for internal purposes.
    """
    @spec eval_hook(:before | :after_return, hook_fun, t) :: term
    def eval_hook(step, fun, hook) do
      fun.(hook)
    rescue
      e ->
        msg = "hook execution failed on step #{inspect(step)} with error #{inspect(e)}"
        reraise RuntimeError, msg, __STACKTRACE__
    end
  end
end