Skip to main content

usage-rules.md

# attached usage rules

Rules apply to `attached ~> 0.1` — **pre-1.0, API may change.**

File attachments for Ecto schemas. Active Storage-inspired, but no
polymorphic associations: `attached` adds a real FK column.
Multi-file attachments are user-defined join schemas, not a macro.

## Minimal pattern

```elixir
defmodule MyApp.Accounts.User do
  use Ecto.Schema
  use Attached.Ecto.Schema                 # adds the attached macro

  schema "users" do
    field :name, :string

    attached :avatar, variants: %{
      thumb:  [resize_to_fill: {100, 100}],
      medium: [resize_to_limit: {400, 400}]
    }
  end
end
```

Goes through the schema's own changeset, via the imported `put_attached/3`:

```elixir
# in MyApp.Accounts.User
def changeset(user, attrs) do
  user
  |> cast(attrs, [:name])
  |> put_attached(:avatar, attrs["avatar"])
end
```

Callers stay Attached-free:

```elixir
%User{} |> User.changeset(params) |> Repo.insert!()
```

## Multi-file attachments

There is intentionally no `attached_many` macro. Write a plain Ecto
join schema with a `belongs_to :original, Attached.Originals.Original`:

```elixir
defmodule MyApp.Blog.ArticleImages.ArticleImage do
  use Ecto.Schema

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id

  schema "article_images" do
    belongs_to :article, MyApp.Blog.Articles.Article
    belongs_to :original, Attached.Originals.Original, type: :binary_id, foreign_key: :attached_original_id
    field :position, :integer
    timestamps()
  end
end
```

Persist the parent first, upload via `Attached.upload_original/2`, then
insert join rows yourself.

## Setup

1. Install migration — creates `attached_originals` and `attached_variants`:

   ```bash
   mix attached.install
   ```

2. Per-attachment migration (convenience helper — write by hand if you prefer):

   ```bash
   mix attached.gen.migration MyApp.Accounts.User avatar
   ```

   Respects `config :attached, default_foreign_key_suffix:`.

3. Configure the service:

   ```elixir
   config :attached,
     storage_backends: [
       local: {Attached.StorageBackends.Disk, root: Path.join(["priv", "attachments"])}
     ],
     repo: MyApp.Repo
   ```

   Backends are named instances; a single entry is the default automatically.
   With several entries, set `config :attached, :default_storage_backend, :name`.

4. Router (for serving files via the built-in plug):

   ```elixir
   forward "/attachments", Attached.Web.Plug
   ```

5. Formatter config:

   ```elixir
   # .formatter.exs
   [import_deps: [:ecto, :ecto_sql, :attached], ...]
   ```

   Exports `attached/1,2` as `locals_without_parens`.

## Core API

| Function | Purpose |
|---|---|
| `put_attached(changeset, field, upload)` | Attach on `attached`. Imported by `use Attached.Ecto.Schema`. Upload runs in `prepare_changes/2` — same transaction as the parent insert/update |
| `Attached.url(record, field)` | URL to original file |
| `Attached.url(record, field, variant)` | URL to named variant (lazy-generated on first hit) |
| `Attached.attached?(record, field)` | Boolean |
| `Attached.with_attached(query, field)` | Preload helper — prevents N+1 |
| `Attached.purge(record, field)` | Sync delete: original + variants + files |
| `Attached.purge_later(record, field)` | Enqueues Oban job to purge |
| `Attached.upload_original(upload, opts)` | Standalone original upload (for join-schema multi-file flow) |

To delete a record and purge attachments in one transaction, compose it yourself:

```elixir
user = Repo.preload(user, [:avatar_attached_original, images: :original])

Repo.transact(fn ->
  Attached.purge_later(user, :avatar)
  Enum.each(user.images, &Attached.Originals.purge_later(&1.original))
  Repo.delete(user)
end)
```

Accepted upload shapes for `put_attached`:
- `%Plug.Upload{}`
- `%{path: path, filename: _, content_type: _}` (e.g. from `consume_uploaded_entries/3`)
- `%Attached.Originals.Original{}` (re-attach an existing original)
- `nil` — no-op (leaves existing attachment)

## Variants

Defined on the schema, generated lazily on first URL hit, cached as
their own variant. Powered by [Vix](https://hex.pm/packages/vix) / libvips.

```elixir
attached :avatar, variants: %{
  thumb:     [resize_to_fill: {100, 100}],
  grayscale: [resize_to_limit: {200, 200}, colourspace: :"b-w"]
}
```

Available ops: `resize_to_fill`, `resize_to_limit`, `resize_to_fit`,
`resize_and_pad`, `crop`, `rotate`, `watermark`.

`watermark` composites an overlay image (logo, badge) onto the result.
Place it after a resize so the overlay lands on the final-size image:

```elixir
attached :photo, variants: %{
  large: [
    resize_to_limit: {2000, 1000},
    watermark: [path: "priv/static/images/logo.png",
                gravity: :south_east, margin: 24, opacity: 0.6, scale: 0.2]
  ]
}
```

Options: `:path` (required), `:gravity` (default `:south_east`; any of
`:north_west`/`:north`/`:north_east`/`:west`/`:center`/`:east`/`:south_west`/`:south`/`:south_east`),
`:margin` (px inset, default `0`; integer for uniform, or `{horizontal, vertical}`),
`:opacity` (`0.0`–`1.0`, default `1.0`),
`:scale` (overlay width as a fraction of the base width; omit for native size).

`:path` may be absolute or relative. A **relative** path is joined onto the
app dir at runtime (like `Plug.Static`'s atom `:from`), so a file under `priv`
resolves both in dev and in a release — not against the cwd. The app defaults to
the one owning the configured repo; override with `config :attached, :otp_app, :my_app`.

## Orphan cleanup

Originals store `owner_table` + `owner_field`. Schedule the sweeper via Oban
cron:

```elixir
config :my_app, Oban,
  plugins: [
    {Oban.Plugins.Cron, crontab: [{"0 3 * * *", Attached.Originals.OriginalPurgeOrphansWorker}]}
  ]
```

## Preloading

**Always use `Attached.with_attached/2`** instead of manual preloads —
it also loads variant records in one go.

```elixir
User
|> Attached.with_attached(:avatar)
|> Repo.all()
```

For single-shot reads on an existing record:

```elixir
user = Repo.get(User, id) |> Repo.preload(:avatar_attached_original)
```

For a join schema, use plain Ecto preloads:

```elixir
Article |> Repo.all() |> Repo.preload(images: :original)
```

## Do

- **`put_attached/3` for `attached`** inside the schema's `changeset/2`.
- **For multi-file**, write a join schema and insert rows by hand after
  the parent exists.
- **Configure the repo globally** (`config :attached, :repo, MyApp.Repo`).
  For dynamic repos, pass `{Mod, :fun}` or a zero-arity function instead.
- **Use `with_attached/2` in list queries.** Manual `Repo.preload(:avatar_attached_original)`
  misses the variant records and causes a second roundtrip per variant URL.
- **Accept `%Plug.Upload{}` and the LiveView map shape.** Both flow through
  `put_attached/3` — don't pre-convert in your controller/LiveView.
- **Purge with `purge_later/2`** when deleting large files on the request
  path. Sync `purge/2` blocks the user-facing action on a storage round-trip.

## Don't

- **Don't preload with `Repo.preload(:avatar)`.** The association is
  `:avatar_attached_original` (add `_attached_original` suffix). `with_attached(:avatar)` is the
  intended API — the suffix is an implementation detail.
- **Don't polymorphic-associate originals manually.** The `owner_table` +
  `owner_field` columns are for orphan cleanup only, not for querying
  "all originals for record X".
- **Don't configure the service per-request.** It's a global pick; swap via
  `config :attached, :default_storage_backend, ...` (a name from the
  `:storage_backends` registry).
- **Don't expect `attached` to work on update if you used a non-UUID
  primary key.** Blob FKs are `:binary_id` — your owner tables must use
  UUIDs too.

## Pre-1.0 caveat

APIs on `Attached.StorageBackends.Behaviour` (custom storage backends) and the variant
transformation pipeline may change in minor versions before 1.0. If you
implement a custom service, pin to a specific minor (`~> 0.1.0`, not
`~> 0.1`) and read `CHANGELOG.md` before upgrading.

## Testing

In tests, use the disk service pointed at a tmp dir:

```elixir
# config/test.exs
config :attached,
  storage_backends: [
    local: {Attached.StorageBackends.Disk, root: Path.join([System.tmp_dir!(), "attached_test"])}
  ],
  repo: MyApp.Repo
```

Remember to clean it up in `setup` or a `on_exit` hook — the disk
service doesn't clear itself between tests.

## Configuration

| Key | Purpose |
|---|---|
| `config :attached, :storage_backends, [name: {Module, config}]` | Storage backend registry (built-in modules: `Attached.StorageBackends.Disk`, `Attached.StorageBackends.S3`) |
| `config :attached, :default_storage_backend, :name` | Default backend instance (optional with a single registry entry) |
| `config :attached, :repo, MyApp.Repo` | Ecto repo (module, `{mod, fun}`, or 0-arity function for dynamic repos) |