EctoMiddleware.Engine (ecto_middleware v2.1.0)

View Source

Internal engine for validating and executing middleware chains.

See EctoMiddleware for information on writing middleware. This module is used internally by EctoMiddleware.Repo to execute middleware chains.

Silencing Deprecation Warnings

During migration from v1 to v2, you may want to silence deprecation warnings. This can be done via application configuration:

# In config/config.exs
config :ecto_middleware, :silence_deprecation_warnings, true

This will suppress all deprecation warnings from EctoMiddleware. Note that this should only be used temporarily during migration - the deprecated APIs will be removed in v3.0.

Middleware Execution

Middleware are executed in a chain. You can think of this as a matroshka doll, where each middleware wraps the next one in the chain.

Each middleware is expected to call yield/2 to continue execution to the next middleware in the chain, but may also choose to halt execution by not calling yield/2 and returning a value directly.

For example, given the following middleware chain:

[LoggerMiddleware, AuthMiddleware, VirtualFieldMiddleware]

Execution proceeds as follows:

  1. c:Ecto.Repo.insert/2 is called.
  2. EctoMiddleware.process/2 is invoked on LoggerMiddleware.
    • It logs "Before insert".
    • It calls yield/2 to continue execution.
  3. EctoMiddleware.process/2 is invoked on AuthMiddleware.
    • It checks authorization.
    • It calls yield/2 to continue execution.
  4. EctoMiddleware.process/2 is invoked on VirtualFieldMiddleware.
    • It immediately calls yield/2 to continue execution.
  5. There aren't any more middleware to execute, so the super function (the actual c:Ecto.Repo.insert/2 call) is invoked.
    • The database insert occurs, returning {:ok, user}.
  6. Control returns to VirtualFieldMiddleware.
    • It resolves some virtual fields on the user struct.
    • It returns the user struct w/ virtual fields up the chain.
  7. Control returns to AuthMiddleware.
    • It doesn't do anything further, so it returns the user struct w/ virtual fields up the chain.
  8. Control returns to LoggerMiddleware.process/2.
    • It logs "After insert: {:ok, user w/ virtual fields}".
    • It returns the user struct up the chain.
  9. The caller of c:Ecto.Repo.insert/2 sees the function result as {:ok, user_with_virtual_fields}.

For simplicity, middleware authors can either implement the EctoMiddleware.process_before/2 or EctoMiddleware.process_after/2 callbacks to only operate in the "before" or "after" phases.

Alternatively, you can implement EctoMiddleware.process/2 for full control, but you must call yield/2 at the appropriate time. Not calling yield/2 will halt execution at that middleware.

Important: yield/2 returns {result, updated_resolution}. You must destructure this tuple when calling yield.

See the EctoMiddleware docs for more information.

Backwards Compatibility

Prior versions of EctoMiddleware (v1.x) had a different middleware contract and execution engine.

In those versions of the library, all middleware were expected to implement middleware/2 in a middleware module (note: this was not a behaviour callback). This function was expected to take some "resource" and return that "resource" transformed by the middleware.

Middleware executed exactly once, either before or after the super function, depending on their position relative to EctoMiddleware.Super in the middleware list.

An example of this follows:

defmodule Repo do
  use EctoMiddleware

  @impl EctoMiddleware
  def middleware(:insert, _resource) do
    [BeforeMiddleware, EctoMiddleware.Super, AfterMiddleware]
  end
end

defmodule BeforeMiddleware do
  def middleware(resource, _resolution) do
    transform_before(resource)
  end
end

defmodule AfterMiddleware do
  def middleware(resource, _resolution) do
    transform_after(resource)
  end
end

The current version of EctoMiddleware (v2.x) supports v1 middleware for backwards compatibility.

This is implemented by dynamically replacing any v1 middleware with a v2 middleware that wraps the v1 middleware and calls its middleware/2 function either before or after the super function is executed.

This functionality is temporary and will be removed in v3.0, at which point all middleware must implement the v2 middleware contract.

Using v1 middleware in this way will emit a deprecation warning. We strongly recommend updating any existing v1 middleware to implement the v2 middleware contract to avoid this warning and ensure compatibility with future versions of EctoMiddleware.

Summary

Functions

Drops middleware that have not opted into bulk operations when the action is a bulk action (insert_all, update_all, delete_all).

Runs a v2 middleware's process_before/2 -> yield/2 -> process_after/2 phases.

Validates each middleware implements the required callbacks.

Yields execution to the next middleware in the chain.

Functions

reject_non_bulk_middleware(middlewares, action)

@spec reject_non_bulk_middleware([term()], atom()) :: [term()]

Drops middleware that have not opted into bulk operations when the action is a bulk action (insert_all, update_all, delete_all).

For non-bulk actions the list is returned unchanged. For bulk actions, only middleware that declared use EctoMiddleware, bulk_operations: true are kept; everything else (single-record middleware, v1 middleware) is filtered out so it is never handed a schema/source or queryable it doesn't expect.

EctoMiddleware.Super is always kept. It is not a middleware but the marker validate_middleware!/1 uses to split a v1 chain into its :before and :after phases; dropping it here would leave that reduce stuck in :before, so an opted-in v1 middleware positioned after Super would be handed the resource instead of the operation's result.

This makes bulk interception opt-in per middleware: a Repo's middleware/2 may keep returning its usual list (including a catch-all clause) and existing middleware remain unaffected by bulk operations until they explicitly opt in.

run_phases(resource, resolution, before_fun, after_fun, normalize_fun)

@spec run_phases(
  resource :: term(),
  resolution :: EctoMiddleware.Resolution.t(),
  before_fun :: (term(), EctoMiddleware.Resolution.t() -> term()),
  after_fun :: (term(), EctoMiddleware.Resolution.t() -> term()),
  normalize_fun :: (term() -> {:cont, term()} | {:halt, term()})
) ::
  {:cont, term(), EctoMiddleware.Resolution.t()}
  | {:halt, term(), EctoMiddleware.Resolution.t()}
  | {:halt, term()}

Runs a v2 middleware's process_before/2 -> yield/2 -> process_after/2 phases.

This is the body of the default process/2 generated by use EctoMiddleware. It lives here (compiled once, with the phase callbacks as opaque function values) rather than being inlined into each middleware module so that the compiler's type checker cannot narrow the callbacks' return types to {:cont, _} and flag the {:halt, _} branches as dead clauses for middleware that never halt.

before_fun/after_fun/normalize_fun are the using module's process_before/2, process_after/2, and normalize/1.

Returns a {:cont | :halt, value, resolution} 3-tuple on the paths where yield/2 ran, so that resolution updates made deeper in the chain -- notably before_output, set at the innermost yield/2 -- propagate back out to middleware wrapping this one. Returning a bare value here would strand those updates, silently breaking any outer middleware that reads them (for example ecto_hooks, which dispatches its after_* hooks off before_output).

validate_middleware!(middlewares)

@spec validate_middleware!([term()]) :: [term()]

Validates each middleware implements the required callbacks.

This function is executed prior to execution of any given middleware chain to ensure that the specified middleware pipeline is valid.

Note: This function also wraps any v1 middleware to be compatible with the v2 middleware execution engine. This functionality is temporary and will be removed in v3 when v1 middleware is no longer supported.

yield(resource, resolution)

@spec yield(resource :: term(), resolution :: EctoMiddleware.Resolution.t()) ::
  {term(), EctoMiddleware.Resolution.t()}

Yields execution to the next middleware in the chain.

Returns a tuple of {result, updated_resolution} where the resolution may have been updated during middleware execution (e.g., for V1 compatibility fields like before_output).

  • If the result of a middleware's process/2 function is {:halt, value}, execution is halted.
  • If there are no more middleware to execute, the super function is invoked.
  • Otherwise, execution continues to the next middleware in the chain.