defmodule EctoMiddleware do @moduledoc """ Behaviour for creating middleware that intercepts and transforms Ecto repository operations. EctoMiddleware provides a composable way to add cross-cutting concerns to your Ecto operations using a middleware pipeline pattern, similar to Plug or Absinthe middleware. ## Quick Example defmodule NormalizeEmail do use EctoMiddleware @impl EctoMiddleware def process_before(changeset, _resolution) do case Ecto.Changeset.fetch_change(changeset, :email) do {:ok, email} -> {:cont, Ecto.Changeset.put_change(changeset, :email, String.downcase(email))} :error -> {:cont, changeset} end end end defmodule MyApp.Repo do use Ecto.Repo, otp_app: :my_app use EctoMiddleware.Repo @impl EctoMiddleware.Repo def middleware(action, resource) when is_insert(action, resource) do [NormalizeEmail, HashPassword, AuditLog] end def middleware(_action, _resource), do: [AuditLog] end ## Core Concepts ### The API Implement `c:process_before/2` to transform data **before** the database operation: defmodule AddTimestamp do use EctoMiddleware @impl EctoMiddleware def process_before(changeset, _resolution) do {:cont, Ecto.Changeset.put_change(changeset, :processed_at, DateTime.utc_now())} end end Implement `c:process_after/2` to process data **after** the database operation: defmodule NotifyAdmin do use EctoMiddleware @impl EctoMiddleware def process_after({:ok, user} = result, _resolution) do Task.start(fn -> send_notification(user) end) {:cont, result} end def process_after(result, _resolution), do: {:cont, result} end Implement `c:process/2` to wrap around the entire operation: defmodule MeasureLatency do use EctoMiddleware @impl EctoMiddleware def process(resource, resolution) do start_time = System.monotonic_time() # Yields control to the next middleware (or Repo operation) in the chain # before resuming here. {result, _updated_resolution} = yield(resource, resolution) duration = System.monotonic_time() - start_time Logger.info("\#{resolution.action} took \#{duration}ns") result end end **Important**: When using `c:process/2`, you must call `yield/2` to continue the middleware chain. ### Halting Execution Return `{:halt, value}` to stop the middleware chain and return a value immediately: defmodule RequireAuth do use EctoMiddleware @impl EctoMiddleware def process(resource, resolution) do if authorized?(resolution) do {result, _} = yield(resource, resolution) result else {:halt, {:error, :unauthorized}} end end defp authorized?(resolution) do get_private(resolution, :current_user) != nil end end ### Guards for Operation Detection `EctoMiddleware.Utils` provides guards to detect operations, especially useful for `:insert_or_update`: defmodule ConditionalMiddleware do use EctoMiddleware @impl EctoMiddleware def process_before(changeset, resolution) when is_insert(changeset, resolution) do {:cont, add_created_metadata(changeset)} end def process_before(changeset, resolution) when is_update(changeset, resolution) do {:cont, add_updated_metadata(changeset)} end def process_before(changeset, _resolution) do {:cont, changeset} end end ## Return Value Conventions ### For `c:process_before/2` and `c:process_after/2` Returning bare values from middleware is supported, but to be explicit, return one of: - `{:cont, value}` - Continue to next middleware - `{:halt, value}` - Stop execution, return `value` - `{:cont, value, updated_resolution}` - Continue with updated `t:EctoMiddleware.Resolution.t/0` - `{:halt, value, updated_resolution}` - Stop with updated `t:EctoMiddleware.Resolution.t/0` Bare values are always treated as `{:cont, value}`. ## Migration from V1 V1 middleware continue to work but emit deprecation warnings. Key differences: - Use `EctoMiddleware.Repo` instead of `EctoMiddleware` in Repo modules - Implement `c:process_before/2`, `c:process_after/2`, or `c:process/2` instead of the deprecated `c:middleware/2` - No need for `EctoMiddleware.Super` marker - Return `{:cont, value}` or `{:halt, value}` instead of bare values See the [Migration Guide](https://hexdocs.pm/ecto_middleware/migration_v1_to_v2.html) for details. ### Silencing Deprecation Warnings During migration, you can silence warnings temporarily: # In config/config.exs config :ecto_middleware, :silence_deprecation_warnings, true This should only be used during migration - deprecated APIs will be removed in v3.0. """ alias EctoMiddleware.Resolution # NOTE: This will be removed in v3.0 @callback middleware( resource :: EctoMiddleware.Repo.resource(), resolution :: Resolution.t() ) :: EctoMiddleware.Repo.resource() @callback process_before(resource :: term(), resolution :: Resolution.t()) :: middleware_result() @callback process_after(result :: term(), resolution :: Resolution.t()) :: middleware_result() @callback process(resource :: term(), resolution :: Resolution.t()) :: middleware_result() @type middleware_result :: term() | {:cont, term()} | {:halt, term()} | {:ok, term()} | {:error, term()} @optional_callbacks middleware: 2, process_before: 2, process_after: 2, process: 2 @doc """ Stubs out the necessary behaviours and imports for implementing an `EctoMiddleware` middleware. For backwards compatibility, if used in an `Ecto.Repo` module, it will emit a deprecation warning and delegate to `use EctoMiddleware.Repo` instead. This behaviour will be removed in v3.0. """ defmacro __using__(_opts) do quote location: :keep do if Module.defines?(__MODULE__, {:__adapter__, 0}) do use EctoMiddleware.Repo if !Application.compile_env(:ecto_middleware, :silence_deprecation_warnings, false) do IO.warn(""" using `use EctoMiddleware` in a Repo module is deprecated. Please use `use EctoMiddleware.Repo` instead. This will be removed in v3.0. """) end else @behaviour EctoMiddleware import EctoMiddleware.Engine, only: [yield: 2] import EctoMiddleware.Resolution, only: [put_private: 3, get_private: 2, get_private: 3] import EctoMiddleware.Utils @spec process_before(term(), Resolution.t()) :: {:cont, term()} | {:halt, term()} def process_before(resource, _resolution), do: {:cont, resource} @spec process_after(term(), Resolution.t()) :: {:cont, term()} | {:halt, term()} def process_after(result, _resolution), do: {:cont, result} # Dialyzer warning: These functions are defoverridable, but dialyzer doesn't know that @dialyzer {:nowarn_function, process: 2} def process(resource, resolution) do case normalize(process_before(resource, resolution)) do {:cont, r} -> {result, updated_resolution} = yield(r, resolution) case normalize(process_after(result, updated_resolution)) do {:cont, final} -> final {:halt, value} -> value end {:halt, value} -> value end end @spec normalize(term() | {:cont, term()} | {:halt, term()} | {:ok, term()} | {:error, term()}) :: {:cont, term()} | {:halt, term()} @dialyzer {:nowarn_function, normalize: 1} defp normalize({:cont, v}), do: {:cont, v} defp normalize({:halt, v}), do: {:halt, v} defp normalize({:ok, _} = ok_tuple) do warn_ambiguous(:ok) {:cont, ok_tuple} end defp normalize({:error, _} = error_tuple) do warn_ambiguous(:error) {:cont, error_tuple} end defp normalize(bare) do warn_bare_return() {:cont, bare} end @spec warn_ambiguous(atom()) :: :ok @dialyzer {:nowarn_function, warn_ambiguous: 1} defp warn_ambiguous(type) do silenced? = EctoMiddleware.Engine.warnings_silenced?() if !silenced? and !Process.get({:ecto_middleware_ambiguous_warned, __MODULE__, type}) do Process.put({:ecto_middleware_ambiguous_warned, __MODULE__, type}, true) IO.warn(""" EctoMiddleware: #{inspect(__MODULE__)} returned #{inspect(type)} tuple without explicit :cont or :halt. For clarity, wrap your return values: {:cont, #{inspect(type)}} # to continue with this value {:halt, #{inspect(type)}} # to stop the middleware chain Returning bare #{inspect(type)} tuples is deprecated and will be removed in v3.0. """) end :ok end @spec warn_bare_return() :: :ok @dialyzer {:nowarn_function, warn_bare_return: 0} defp warn_bare_return do silenced? = EctoMiddleware.Engine.warnings_silenced?() if !silenced? and !Process.get({:ecto_middleware_bare_warned, __MODULE__}) do Process.put({:ecto_middleware_bare_warned, __MODULE__}, true) IO.warn(""" EctoMiddleware: #{inspect(__MODULE__)} returned a bare value without wrapping. Please wrap your return values: {:cont, value} # to continue {:halt, value} # to stop Bare returns are deprecated and will be removed in v3.0. """) end :ok end defoverridable process_before: 2, process_after: 2, process: 2 end end end end