Absinthe middleware that projects an incoming query's selection set into an
exact Repo.preload/2 tree.
Declare it per field, naming the root Ecto schema of the field's return type:
query do
field :order, :order do
middleware(AbsintheProjector, schema: MyApp.Order)
resolve(&Resolvers.order/3)
end
endBefore the resolver runs, the middleware normalizes every level of the child
selection through Absinthe's projection API, feeds the resulting field tree
plus the declared schema to AbsintheProjector.Engine.project/2, and stores
the nested preload tree in resolution.context[:absinthe_projector]. The
resolver then hands that tree to its domain service (via
AbsintheProjector.preloads/1):
def order(_parent, %{id: id}, resolution) do
preloads = AbsintheProjector.preloads(resolution)
{:ok, Orders.get_order!(id, preloads)}
endThe middleware behaves identically on query and mutation fields, and is idempotent — reapplying it recomputes and overwrites the same context key with the same value.
Pagination envelopes
List/paginated fields whose entities live inside a wrapper (Flop's data, or a
multi-level page { entries }) add the opt-in :envelope option so projection
starts inside that wrapper instead of at the field root:
field :orders, :order_page do
middleware(AbsintheProjector, schema: MyApp.Order, envelope: :data)
resolve(&Resolvers.list_orders/3)
end:envelope accepts a single atom (:data), a list of atoms
([:page, :entries]) descended in order, or is absent (no envelope — projection
from the field root). Only the innermost selection is projected against
:schema; sibling fields outside the envelope (meta, total) are never
projected, and a query that omits the envelope field stores []. See
AbsintheProjector.Envelope for the descent contract. Everything else — context
key, tree format, error pass-through, idempotency — is identical to a
single-record field.
Abstract GraphQL types
The projected path must consist of concrete GraphQL object types. Interfaces
and unions are rejected with ArgumentError before the resolver runs when they
appear at the middleware field, in an envelope component, or in an Ecto
association being projected.
This is intentional: middleware executes before the resolver has produced a
value, so it cannot know which concrete member of an interface or union will be
returned. A heterogeneous result would require one preload plan per concrete
Ecto schema rather than the single exact tree returned by preloads/1. Attach
the middleware to a concrete field instead, or perform type-specific loading in
the resolver for that abstract field.
Validation
The :schema option is validated eagerly and fail-loud at the top of call/2,
before any error pass-through, so a static misconfiguration never hides behind
an unrelated runtime error:
- omitting
:schemaraisesArgumentErrorwith the declaration usage; - passing a module that is not an Ecto schema raises
ArgumentErrornaming the module (delegated toAbsintheProjector.Introspection, the single validation point); - an
:envelopevalue that is neither an atom,nil, nor a list of atoms raisesArgumentErrornaming the received value, so a mistyped envelope path never silently degrades to an empty preload tree; - an interface or union anywhere in the projected path raises
ArgumentError, because there is no concrete type before the resolver runs.
Error pass-through
When the incoming resolution already carries errors (resolution.errors != []),
the middleware is a strict pass-through: it returns the resolution unchanged,
with no projection and no context write, so upstream failures are never masked
or reordered.
Empty result
When the selection contains no associations, the stored tree is [] (never
nil), so a downstream Repo.preload/2 is always a safe no-op.
Summary
Types
A nested Ecto preload tree, in the exact shape accepted by Repo.preload/2:
a list whose entries are bare association names (leaves) or
{association, subtree} pairs, e.g. [:customer, items: [product: [:supplier]]].
Functions
Middleware entry point. See the module documentation for the full contract.
Returns the namespaced context key the middleware writes the preload tree to.
Returns the preload tree the middleware stored on resolution.
Types
@type preload_tree() :: [atom() | {atom(), preload_tree()}]
A nested Ecto preload tree, in the exact shape accepted by Repo.preload/2:
a list whose entries are bare association names (leaves) or
{association, subtree} pairs, e.g. [:customer, items: [product: [:supplier]]].
Functions
@spec call( Absinthe.Resolution.t(), keyword() ) :: Absinthe.Resolution.t()
Middleware entry point. See the module documentation for the full contract.
Validates the :schema option eagerly, passes an already-errored resolution
through untouched, and otherwise recursively normalizes and stores the
projected preload tree under the :absinthe_projector context key.
@spec context_key() :: atom()
Returns the namespaced context key the middleware writes the preload tree to.
The same atom call/2 stores under and preloads/1 reads from — exposed so
advanced consumers (Absinthe plugins, telemetry wrappers) can read the context
directly without hardcoding the atom:
iex> AbsintheProjector.context_key()
:absinthe_projector
@spec preloads(Absinthe.Resolution.t()) :: preload_tree()
Returns the preload tree the middleware stored on resolution.
Reads resolution.context[:absinthe_projector], the tree computed by
call/2 from the operation's projected selection set, and returns it
unmodified — ready to pass straight to Repo.preload/2 or an Ecto
preload(^tree) composition:
def order(_parent, %{id: id}, resolution) do
preloads = AbsintheProjector.preloads(resolution)
{:ok, Orders.get_order!(id, preloads)}
endWhen the middleware never ran on this field (the context key is absent), it
returns []. That makes the read side safe for shared resolver helpers, which
can call preloads/1 unconditionally and behave identically on projected and
non-projected fields — and, because call/2 always stores a list ([] for a
selection with no associations, never nil), the result is always a valid,
safe-no-op argument to Repo.preload/2.
iex> resolution = %Absinthe.Resolution{context: %{absinthe_projector: [:customer]}}
iex> AbsintheProjector.preloads(resolution)
[:customer]
iex> AbsintheProjector.preloads(%Absinthe.Resolution{context: %{}})
[]