# `Coelho.Ash.Type`
[🔗](https://github.com/nseaSeb/coelho/blob/main/lib/coelho/ash/type.ex#L1)

A Coelho document as an Ash attribute, stored in a `:map` column.

`Coelho.Ecto.Type` targets an Ecto schema, and Ash does not go through
`Ecto.Type` for its own attributes, so an Ash resource declaring
`attribute :body, :map` gets a map and no validation at all.

## Why this is a `use` rather than a ready-made type

Coelho does not depend on Ash — not even optionally. An optional
dependency would be the usual way to ship a module that needs another
library at compile time, but Ash depends on `:stream_data` in every
environment, and Coelho keeps `:stream_data` to `:dev` and `:test` for its
property tests. Reconciling the two means loosening Coelho's own
dependencies for every application that will never use Ash.

So the type is a macro that expands in *your* application, where Ash is
present by definition. It costs one module:

    defmodule MyApp.RichText.Type do
      use Coelho.Ash.Type
    end

and then reads the way any other Ash type does:

    attribute :cgv_doc, MyApp.RichText.Type do
      constraints document_schema: MyApp.RichText.cgv_schema()
    end

The schema arrives as a **constraint** rather than as an option on the
attribute, because that is where Ash puts per-attribute configuration and
where `Ash.Resource.Info` will show it.

## Writing past the cast

Validation lives in `cast_input/2`, which is what a changeset built from
arguments calls. A value set on a changeset directly — `force_change_attribute`
and friends — is not cast, here as anywhere in Ash, and `dump_to_native/2`
writes what it is given. The row then renders as an error rather than as a
page, since `Coelho.Render` raises on a node type the schema does not
declare. The atomic path is not affected: `cast_atomic/2` refuses an
expression and routes a literal through this type's own validation.

## Keeping the column you already have

`storage_type/1`, `cast_stored/2`, `cast_input/2`, `dump_to_native/2`,
`cast_atomic/2` and `constraints/0` are all overridable, which is what lets
a field that is already a `:string` column become a document without a
migration: encode on the way down, decode on the way up, and put the
fallback for rows that still hold plain text inside the type — the one
place every reader goes through by construction rather than by discipline.

    defmodule MyApp.RichText.Type do
      use Coelho.Ash.Type

      @impl true
      def storage_type(_constraints), do: :string

      @impl true
      def dump_to_native(value, constraints) do
        case super(value, constraints) do
          # A column that never holds NULL is a column `is_nil/1` never
          # finds and `allow_nil?` never catches: `JSON.encode!(nil)` is
          # the four characters "null", not the absence of a value.
          {:ok, nil} -> {:ok, nil}
          {:ok, document} -> {:ok, JSON.encode!(document)}
          other -> other
        end
      end

      @impl true
      def cast_stored(value, constraints) when is_binary(value) do
        # Decoding *succeeding* is not enough: "123", "true" and "null" are
        # valid JSON, so a legacy row holding one of them would be read as
        # a number rather than as the text somebody typed — and land as an
        # empty document with nothing said about it. A document is a map.
        case JSON.decode(value) do
          {:ok, document} when is_map(document) -> super(document, constraints)
          _not_a_document -> super(MyApp.RichText.from_plain_text(value), constraints)
        end
      end

      def cast_stored(value, constraints), do: super(value, constraints)
    end

Validation still runs on the way in: what is overridden is where the bytes
go, never whether the document is one.

## Constraints

  * `:document_schema` — required, the `Coelho.Schema` to validate against
  * `:sanitize?` — when true, a value that fails validation is put through
    `Coelho.Document.sanitize/2` and accepted instead of rejected.
    Defaults to `false`. Turn it on for an import path where refusing the
    whole document is worse than keeping a poorer one; leave it off
    wherever a person is typing, so they are told rather than silently
    corrected

## What casting accepts

  * a document map, as ProseMirror's `toJSON()` produces it
  * a JSON string, which is what a form posts back from the editor's
    hidden input
  * `nil` and `""`, which cast to `nil`

## What an invalid document looks like

An `Ash.Error.Changes.InvalidAttribute` on the attribute, whose `vars`
carry the location in the document tree — which is what lets a LiveView
form say more than "is invalid":

    %Ash.Error.Changes.InvalidAttribute{
      field: :cgv_doc,
      message: "is not valid rich text (%{location}: %{reason})",
      vars: [location: "content[0].attrs.href", reason: "scheme \"javascript\" is not allowed", ...]
    }

`vars[:errors]` holds every failure, formatted, not only the first.

## Atomic updates

**A document cannot be updated atomically from an expression, and never
will be.** Validating one means walking its tree in Elixir — resolving node
types against the schema, matching content expressions, running attribute
validators — and none of that can be handed to the database. `cast_atomic/2`
therefore answers `{:not_atomic, reason}` with that sentence in it, so an
action declaring `require_atomic? true` refuses with something a reader can
act on rather than a shrug.

A literal document is cast the ordinary way. Ash does that itself for a
type like this one — it only calls `cast_atomic/2` for an expression, or
once the type defines `handle_change/3` or `prepare_change/3` — and the
generated callback answers the same thing Ash would, so the two paths
cannot drift.

What *does* go through atomically is an update given the document itself:

    # validated and applied, atomic or not
    Ash.Changeset.for_update(post, :update, %{body: document})

    # refused: nothing can validate this without reading it back
    Ash.Changeset.for_update(post, :update, %{})
    |> Ash.Changeset.atomic_update(:body, expr(fragment("? || ?", body, ^more)))

So an action that touches this attribute wants `require_atomic? false`, and
a bulk action over it will fall back to reading rows. That is the price of
the document being validated at all; storing HTML and filtering tags would
atomically store whatever it was given.

## Storage, and tenants

There is nothing to configure. The attribute is a `:map`, so AshPostgres
stores it as `jsonb` in the row that owns it — no side table, no join, and
nothing about it interacts with `AshPostgres` multitenancy: a document
belongs to the row, and the row belongs to wherever your tenancy puts it,
schema-based or attribute-based alike.

Attachments are the one place where that stops being automatic, because
their bytes are not in the row. A key is opaque and global on purpose: it
answers "what does this document point at" and never "whose is it". **What
decides whose it is comes from the connection, never from the key** — the
key arrives in a URL, which is to say from whoever sent the request. See
the `:authorize` option of `Coelho.Plug.Attachments`, and
`Coelho.Attachment.generate_key/1` on why a key prefix is an inventory aid
and not a boundary.

## Loading

Values already in the database are loaded without being re-validated, for
the reason `Coelho.Ecto.Type` gives: a schema that grew stricter after
rows were written would otherwise make old rows unreadable, which is a
migration to run deliberately. Put a stored document through
`Coelho.Document.sanitize/2` before rendering it.

# `cast_input`

```elixir
@spec cast_input(
  term(),
  keyword()
) :: {:ok, map() | nil} | {:error, keyword()}
```

Casts user supplied input — a document map, or the JSON a form posts back.

# `cast_stored`

```elixir
@spec cast_stored(
  term(),
  keyword()
) :: {:ok, map() | nil}
```

Reads a value back out of the column, without re-validating it.

# `constraints`

```elixir
@spec constraints() :: keyword()
```

The constraint schema the generated type declares.

# `dump_to_native`

```elixir
@spec dump_to_native(
  term(),
  keyword()
) :: {:ok, map() | nil} | :error
```

Writes a value to the column.

# `not_atomic_reason`

```elixir
@spec not_atomic_reason() :: String.t()
```

Why a document cannot be updated atomically from an expression.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
