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
endand then reads the way any other Ash type does:
attribute :cgv_doc, MyApp.RichText.Type do
constraints document_schema: MyApp.RichText.cgv_schema()
endThe 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)
endValidation still runs on the way in: what is overridden is where the bytes go, never whether the document is one.
Constraints
:document_schema— required, theCoelho.Schemato validate against:sanitize?— when true, a value that fails validation is put throughCoelho.Document.sanitize/2and accepted instead of rejected. Defaults tofalse. 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
niland"", which cast tonil
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
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.
@spec constraints() :: keyword()
The constraint schema the generated type declares.
Writes a value to the column.
@spec not_atomic_reason() :: String.t()
Why a document cannot be updated atomically from an expression.