Published on

Polymorphic embeds in Ecto

Recently I had to ensure semi-arbitrary data for an embedded schema could be validated and easily mapped in Phoenix forms. I didn’t need to store this data in the database. After tinkering with it for a bit polymorphic embeds was the solution.

Digging into Ecto I figured out how I could mostly extend the way the native Ecto embed works to get polymorphic embeds working.

Let’s start first by setting up the embedded schema that requires polymorphic embed:

defmodule MyApp.Accounts.Payment do
  use Ecto.Schema

  import Ecto.Changeset

  alias MyApp.Accounts.{Account, Type1, Type2}
  alias MyApp.PolymorphicEmbed

  embedded_schema do
    belongs_to :account, Account

    field :amount, :integer
    field :metadata, PolymorphicEmbed
  end

  def changeset(payment_or_changeset, attrs) do
    payment_or_changeset
    |> cast(attrs, [:amount])
    |> validate_required([:amount, :account_id])
    |> cast_metadata_embed()
  end

  defp cast_metadata_embed(changeset) do
    schema =
      case get_field(changeset, :account) do
        %{type: "type1"} -> Type1
        %{type: "type2"} -> Type2
      end

    PolymorphicEmbed.cast_polymorphic_embed(changeset, schema, :metadata)
  end
end

The metadata schema would look something like this:

 defmodule MyApp.Accounts.Type1 do
  use Ecto.Schema

  import Ecto.Changeset

  @primary_key false

  embedded_schema do
    field :field_1, :string
  end

  def changeset(changeset, attrs) do
    changeset
    |> cast(attrs, [:field_1])
    |> validate_required([:field_1])
  end
end

This is a simplified example from the project I was working on. As you can see we want the metadata to be polymorphic, decided by the parent account. We’re going to build the PolymorphicEmbed Ecto type and its cast_polymorphic_embed/3 function.

First, we’ll build the parameterized type:

defmodule MyApp.PolymorphicEmbed do
  @moduledoc """
  Polymorphic metadata is using Ecto's native embedded type.

  The data type is made polymorphic by passing in the schema module when
  casting the field.
  """

  defstruct [
    :cardinality,
    :related,
    :on_cast
  ]

  use Ecto.ParameterizedType

  alias __MODULE__

  @impl Ecto.ParameterizedType
  def type(_params), do: :map

  @impl Ecto.ParameterizedType
  def init(opts) do
    opts =
      opts
      |> Keyword.put_new(:on_replace, :raise)
      |> Keyword.put_new(:cardinality, :one)

    struct(%PolymorphicEmbed{}, opts)
  end

  @impl Ecto.ParameterizedType
  defdelegate load(value, fun, opts), to: Ecto.Embedded

  @impl Ecto.ParameterizedType
  defdelegate cast(data, params), to: Ecto.Embedded

  @impl Ecto.ParameterizedType
  defdelegate dump(value, fun, embed), to: Ecto.Embedded

  alias Ecto.Changeset.Relation

  @behaviour Relation

  @impl Relation
  def build(%PolymorphicEmbed{related: related}, _owner) do
    related.__struct__()
  end
end

We want Ecto to handle this type just like a normal embed. The first part of this is done by creating a struct with :cardinality, :related, and :on_cast keys.

One caveat with the above is that we won’t be able to use this in a database the same way as embeds. We’re just passing the load, cast, and dump functions straight through to Ecto.Embedded. There is not a way to load it back into the struct without either storing the struct somewhere, or always making sure we run it through the changeset. As I didn’t need to store this in the database I didn’t bother to make that work.

Next up is adding the cast_polymorphic_embed/3 function:

defmodule MyApp.PolymorphicEmbed do
  # ...

  alias Ecto.Changeset

  # This is forcing an embed to be polymorphic by defining the
  # schema module on runtime.
  def cast_polymorphic_embed(changeset, related, name) do
    %{types: types} = changeset

    # The related schema module will be set as the embedded type here.
    {:parameterized, __MODULE__, relation} = Map.fetch!(types, name)
    relation = %{relation | related: related}

    # We must modify the type so Ecto will handle this field as an embed.
    changeset = %{changeset | types: Map.put(changeset.types, name, {:embed, relation})}

    Changeset.cast_embed(changeset, name)
  end
end

When Phoenix maps a changeset to a form it’ll use the type derived from the schema to figure out what keys exist. We’re manipulating the changeset types ensuring it looks like a regular hardcoded embed when casting the polymorphic embed.

This is a double win since when we modify the type in the changeset we can also just depend on the cast_embed/2 function to cast it.

Now we can use it just like any embed in our form:

<.simple_form for={@form}>
  <.inputs_for :let={metadata_form} field={@form[:metadata]}>
    <.input type="text" field={metadata_form[:field_1]} label="Field 1" :if={Map.has_key?(metadata_form.data, :field_1)}> />
  </.inputs_for>
</.simple_form>

The :if attribute is necessary as the metadata fields will differ. We only want to display the input field if it exists in the metadata schema.

Any place you are going to use the changeset, remember to always run it through the changeset/2 function containing the cast_polymorphic_embed/3 otherwise the polymorphic embed will not be set.

Hi, I'm Dan Schultzer, I write in this blog, work a lot in Elixir, maintain several open source projects, and help companies streamline their development process