Generalization & Specialization and Changesets

Here’s some simplified code, maybe it’ll help.

Behavior module:

defmodule App.Contents.Element do  
  @doc """
  Mandatory implementation of the embed data changeset.
  """
  @callback data_changeset(struct(), map()) :: Changeset.t

  @doc """
  Optional implementation of the whole embed changeset, this is the wrapper
  changeset that uses above data changeset but also handles files.

  Only override if some special tweaking is required on this level.
  """
  @callback embed_changeset(struct(), map()) :: Changeset.t

  # other  callbacks

  @doc false
  defmacro __using__(opts) do
    quote [location: :keep] do
      use Ecto.Schema

      import Ecto.Changeset
      
      @behaviour Element
      @opts unquote(opts)

      unless @opts[:data] do
        raise("Please define the fields as data options key")
      end

      @doc """
      Default implementation of the embed changeset.
      """
      @impl true
      @spec embed_changeset(struct(), map()) :: Changeset.t
      def embed_changeset(struct, params) do
        struct
        |> data_changeset(params)
        |> cast_uuids(params)
        |> handle_uuids()
        |> handle_files(params)
      end

      # other default callback implementations

      defoverridable Element

      schema "contents" do
        field :order, :integer
        field :type, :string

        # other globally needed feidls

        embeds_one :data, Data, [on_replace: :delete] do
          # no @opts available since technically it's a declaration of another
          # (child) module
          el_opts = unquote(opts)
          for {key, type} <- el_opts[:data] do
            field(key, type)
          end

          # further things like files etc
        end

        timestamps()
      end

      # private (not overridable) functions go here
  end
end

example module that uses it:

defmodule AppWeb.Elements.Headline do
  @moduledoc """
  A headline content element.
  """

  alias AppWeb.ElementView

  use App.Contents.Element, [
    data: [
      {:level, :integer},
      {:text, :string}
    ]
  ]

  @impl true
  def data_changeset(struct, params) do
    struct
    |> cast(params, [:level, :text])
    |> validate_required([:level, :text])
    |> validate_inclusion(:level, 1..6)
  end
  
  # other callbacks go here
end

So basically each “using” module is rewritten to a whole schema / embedded schema with duplicated code, but you only have to write it once and let it duplicate itself :slight_smile:

2 Likes