AbsintheProjector (AbsintheProjector v0.1.0)

Copy Markdown View Source

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
end

Before the resolver runs, the middleware obtains the projected child fields via Absinthe.Resolution.project/1, feeds them plus the declared schema to AbsintheProjector.Engine.project/2, and stores the resulting 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)}
end

The 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.

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 :schema raises ArgumentError with the declaration usage;
  • passing a module that is not an Ecto schema raises ArgumentError naming the module (delegated to AbsintheProjector.Introspection, the single validation point);
  • an :envelope value that is neither an atom, nil, nor a list of atoms raises ArgumentError naming the received value, so a mistyped envelope path never silently degrades to an empty preload tree.

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

preload_tree()

@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

call(resolution, opts)

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 stores the projected preload tree under the :absinthe_projector context key.

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

preloads(resolution)

@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)}
end

When 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: %{}})
[]