<p align="center">
  <img src="logo.png" alt="AbsintheProjector" width="420">
</p>

<h1 align="center">AbsintheProjector</h1>

<p align="center">
  <a href="https://hex.pm/packages/absinthe_projector"><img src="https://img.shields.io/hexpm/v/absinthe_projector.svg" alt="Hex.pm"></a>
  <a href="https://hexdocs.pm/absinthe_projector"><img src="https://img.shields.io/badge/hexdocs-docs-8e7ce6.svg" alt="HexDocs"></a>
  <a href="https://hex.pm/packages/absinthe_projector"><img src="https://img.shields.io/hexpm/l/absinthe_projector.svg" alt="License"></a>
</p>

<p align="center">
  Ecto preload trees derived from Absinthe selection sets.
</p>

---

**AbsintheProjector** is an [Absinthe](https://hexdocs.pm/absinthe) middleware that converts each query's selection set into an exact Ecto preload tree — discovered from your Ecto schemas by reflection, never a hand-maintained list — ready to drop into a single `Repo.preload/2`.

```graphql
{
  order {
    number
    customer { name }
    items { product { supplier { name } } }
  }
}
```

becomes, automatically:

```elixir
[:customer, items: [product: [:supplier]]]
```

No association N+1. No association overfetch. No rewriting resolvers. No spreading dataloader across your schema.

## Why

Every Absinthe + Ecto API picks one of two defaults:

- **Fixed preloads in the resolver** — you preload everything any client *might* ask for, on every request. Most of it is wasted queries.
- **Dataloader** — solves N+1 by batching, but asks you to restructure your schema with per-field resolvers, which fights the common "domain service returns a fully-loaded struct" architecture.

There is a third option hiding in plain sight: Absinthe already knows exactly what the client selected (`Absinthe.Resolution.project/1`), and Ecto already knows which fields are associations (`__schema__(:associations)`). AbsintheProjector just introduces them to each other.

|                          | Fixed preloads | Dataloader        | AbsintheProjector    |
| ------------------------ | -------------- | ----------------- | -------------------- |
| Loads only what's asked  | ❌             | ✅                | ✅                   |
| Keeps thin resolvers     | ✅             | ❌ per-field      | ✅ one middleware    |
| Works with domain services returning loaded structs | ✅ | ❌ | ✅ |
| Config per association   | none           | source per type   | none (reflection)    |

Dataloader is still the right tool for batching across *many parent records resolved independently*. AbsintheProjector shines when your resolver loads a record (or a page of records) at the top and wants its associations preloaded in one shot — the dominant shape in service-layer architectures.

## Installation

```elixir
def deps do
  [
    {:absinthe_projector, "~> 0.1.0"}
  ]
end
```

Requires Elixir ~> 1.14, `absinthe ~> 1.7`, `ecto ~> 3.10`.

## Quick start

Declare the middleware on a field, naming the root Ecto schema of that field's return type:

```elixir
query do
  field :order, :order do
    arg(:id, non_null(:id))
    middleware(AbsintheProjector, schema: MyApp.Order)
    resolve(&Resolvers.get_order/2)
  end
end
```

Read the computed preload tree in the resolver and pass it down as plain data:

```elixir
def get_order(%{id: id}, resolution) do
  preloads = AbsintheProjector.preloads(resolution)
  Orders.get(id, preloads)
end
```

Your domain code stays Absinthe-free — the tree is an ordinary keyword list that ends up in `Repo.preload/2` (or a `preload(^tree)` query composition):

```elixir
def get(id, preloads \\ []) do
  case Repo.get(Order, id) do
    nil -> {:error, :not_found}
    order -> {:ok, Repo.preload(order, preloads)}
  end
end
```

That's the whole integration. Scalar fields, `__typename`, aliases, fragments and duplicate selections are all handled; anything that isn't a real association of the schema is ignored.

## Pagination envelopes

List fields that wrap entities in a pagination envelope ([Flop](https://hexdocs.pm/flop)'s `data`, or a custom `page { entries }`) declare where the entities live with `:envelope`:

```elixir
field :orders, :order_page do
  middleware(AbsintheProjector, schema: MyApp.Order, envelope: :data)
  resolve(&Resolvers.list_orders/2)
end
```

`:envelope` takes an atom (`:data`) or a list descended in order (`[:page, :entries]`). Sibling fields outside the envelope (`meta`, `totalCount`, …) are never projected, and a query that skips the envelope entirely yields `[]` — always a safe no-op for `Repo.preload/2`.

## How it works

1. `Absinthe.Resolution.project/1` gives the middleware the client's selection set, already normalized — fragments expanded, `@skip`/`@include` applied, each field carrying its schema identifier.
2. The optional envelope path is descended structurally.
3. The engine walks the selection recursively, asking Ecto's reflection API at every level: *is this field a real association, and which schema do I recurse into?* — including `has_through` chains. Adding an association to a schema makes it projectable automatically; there is no whitelist to maintain, so there is no whitelist to drift.
4. The resulting tree is stored on `resolution.context`; `AbsintheProjector.preloads/1` reads it back (`[]` when the middleware didn't run, so shared resolver helpers can call it unconditionally).

Everything is pure: no process state, no database access, no configuration.

### Fail-loud by design

Static misconfiguration surfaces immediately instead of degrading into an empty preload:

- missing `:schema` → `ArgumentError` with usage guidance;
- `:schema` that isn't an Ecto schema module → `ArgumentError` naming the module;
- malformed `:envelope` → `ArgumentError` naming the value.

A resolution that already carries errors passes through untouched — upstream failures are never masked.

## Troubleshooting

**Installed the middleware and preloads come back empty?** Force a recompile:

```bash
mix compile --force
```

Absinthe compiles your schema's imported type modules into a cached blueprint; swapping middleware inside an `import_types` module doesn't always invalidate it. This is an Absinthe compilation quirk, not specific to this library — one `--force` after wiring the middleware and you're done.

## License

MIT — see [LICENSE](https://github.com/patric-vinicios/absinthe_projector/blob/main/LICENSE).
