Coelho.Ash.Type (coelho v0.14.0)

Copy Markdown View Source

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.

Summary

Functions

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

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

The constraint schema the generated type declares.

Writes a value to the column.

Why a document cannot be updated atomically from an expression.

Functions

cast_input(value, constraints)

@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(value, constraints)

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

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

constraints()

@spec constraints() :: keyword()

The constraint schema the generated type declares.

dump_to_native(value, constraints)

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

Writes a value to the column.

not_atomic_reason()

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

Why a document cannot be updated atomically from an expression.