polymorphic_embed brings support for polymorphic/dynamic embedded schemas in Ecto.
Ecto's embeds_one and embeds_many macros require a specific schema module to be specified. This library removes this restriction by dynamically determining which schema to use, based on data to be stored (from a form or API) and retrieved (from the data source).
Let's say we want a schema Reminder representing a reminder for an event, that can be sent either by email or SMS.
We create the Email and SMS embedded schemas containing the fields that are specific for each of those communication channels.
The Reminder schema can then contain a :channel field that will either hold an Email or SMS struct, by setting its type to the custom type PolymorphicEmbed that this library provides.
Find the schema code and explanations below.
defmodule MyApp.Reminder do use Ecto.Schema import Ecto.Changeset import PolymorphicEmbed schema "reminders" do field :date, :utc_datetime field :text, :string polymorphic_embeds_one :channel, types: [ sms: MyApp.Channel.SMS, email: MyApp.Channel.Email ], on_type_not_found: :raise, on_replace: :update end def changeset(struct, values) do struct |> cast(values, [:date, :text]) |> cast_polymorphic_embed(:channel, required: true) |> validate_required(:date) end enddefmodule MyApp.Channel.Email do use Ecto.Schema import Ecto.Changeset @primary_key false embedded_schema do field :address, :string field :confirmed, :boolean end def changeset(email, params) do email |> cast(params, [:address, :confirmed]) |> validate_required(:address) |> validate_length(:address, min: 4) end enddefmodule MyApp.Channel.SMS do use Ecto.Schema import Ecto.Changeset @primary_key false embedded_schema do field :number, :string end def changeset(sms, params) do sms |> cast(params, [:number]) |> validate_required(:number) end endIn your migration file, you may use the type :map for both polymorphic_embeds_one/2 and polymorphic_embeds_many/2 fields.
add(:channel, :map)It is not recommended to use {:array, :map} for a list of embeds.
cast_polymorphic_embed/3 must be called to cast the polymorphic embed's parameters.
-
:required– if the embed is a required field. -
:with– allows you to specify a custom changeset.
changeset |> cast_polymorphic_embed(:channel, with: [ sms: &SMS.custom_changeset/2, email: &Email.custom_changeset/2 ] )-
:drop_param– see sorting-and-deleting-from-many-collections. -
:sort_param– see sorting-and-deleting-from-many-collections. -
:default_type_on_sort_create– in some cases,sortcreates a new entry; this option specifies which type to use by default for the entry.
The :types option for the PolymorphicEmbed custom type contains a keyword list mapping an atom representing the type (in this example :email and :sms) with the corresponding embedded schema module.
There are two strategies to detect the right embedded schema to use:
[sms: MyApp.Channel.SMS]When receiving parameters to be casted (e.g. from a form), we expect a "__type__" (or :__type__) parameter containing the type of channel ("email" or "sms").
[email: [ module: MyApp.Channel.Email, identify_by_fields: [:address, :confirmed]]]Here we specify how the type can be determined based on the presence of given fields. In this example, if the data contains :address and :confirmed parameters (or their string version), the type is :email. A "__type__" parameter is then no longer required.
Note that you may still include a __type__ parameter that will take precedence over this strategy (this could still be useful if you need to store incomplete data, which might not allow identifying the type).
Lists of polymorphic embeds are also supported:
polymorphic_embeds_many :contexts, types: [ location: MyApp.Context.Location, age: MyApp.Context.Age, device: MyApp.Context.Device ], on_replace: :delete, on_type_not_found: :raise, nilify_unlisted_types_on_load: [:deprecated_type]-
:types– discussed above. -
:type_field_name– specify a custom field name for the field holding the type. Defaults to:__type__. -
:use_parent_field_for_type– fetch the type from a specified field in the parent schema (the schema holding the embed). -
:on_type_not_found– specify what to do if the embed's type cannot be inferred. Possible values are:raise: raise an error:changeset_error: add a changeset error:nilify: replace the data bynil; only for single (non-list) embeds:ignore: ignore the data; only for lists of embeds
By default, a changeset error "is invalid" is added.
-
:on_replace– mandatory option that can only be set to:updatefor a single embed and:deletefor a list of embeds (we force a value as the default value of this option forembeds_oneandembeds_manyis:raise). -
:retain_unlisted_types_on_load: allow unconfigured types to be loaded without raising an error. Useful for handling deprecated structs still present in the database. -
:nilify_unlisted_types_on_load: same as:retain_unlisted_types_on_load, but nilify the struct on load.
The library comes with a form helper in order to build form inputs for polymorphic embeds and display changeset errors.
In the entrypoint defining your web interface (lib/your_app_web.ex file), add the following import, change view to the one your component use:
def view do quote do # imports and stuff import PolymorphicEmbed.HTML.Form end endThis provides you with the polymorphic_embed_inputs_for/3 and polymorphic_embed_inputs_for/4 functions.
Here is an example form using the imported function:
<%= inputs_for f, :reminders, fn reminder_form -> %> <%= polymorphic_embed_inputs_for reminder_form, :channel, :sms, fn sms_form -> %> <div class="sms-inputs"> <label>Number</label> <%= text_input sms_form, :number %> <div class="error"> <%= error_tag sms_form, :number %> </div> </div> <% end %> <% end %>When using polymorphic_embed_inputs_for/4, you have to manually specify the type. When the embed is nil, empty fields will be displayed.
polymorphic_embed_inputs_for/3 doesn't require the type to be specified. When the embed is nil, no fields are displayed.
They both render a hidden input for the "__type__" field.
You may use PolymorphicEmbed.HTML.Component.polymorphic_embed_inputs_for/1 when working with LiveView, which functions similarly to Phoenix.Component.inputs_for/1.
<.form :let={f} for={@changeset} id="reminder-form" phx-change="validate" phx-submit="save" > <.polymorphic_embed_inputs_for field={f[:channel]} :let={channel_form}> <%= case source_module(channel_form) do %> <% SMS -> %> <.input field={channel_form[:number]} label="Number" /> <% Email -> %> <.input field={channel_form[:address]} label="Email Address" /> <% end %> </.polymorphic_embed_inputs_for> </.form>Sometimes you need to serialize the polymorphic embed and, once in the front-end, need to distinguish them. PolymorphicEmbed.get_polymorphic_type/3 returns the type of the polymorphic embed:
PolymorphicEmbed.get_polymorphic_type(Reminder, :channel, SMS) == :smsTo get the module for a specific type, use:
PolymorphicEmbed.get_polymorphic_module(Reminder, :channel, :sms) == SMSThe function Ecto.changeset.traverse_errors/2 won't include the errors of polymorphic embeds. You may instead use PolymorphicEmbed.traverse_errors/2 when working with polymorphic embeds.
Add polymorphic_embed for Elixir as a dependency in your mix.exs file:
def deps do [ {:polymorphic_embed, "~> 5.0"} ] endHexDocs documentation can be found at https://hexdocs.pm/polymorphic_embed.