Avoiding duplication in Ecto.Schema

I don’t like having to write the same thing in multiple places. I know it’s not a big deal, but it annoys me.

Namely, the schema definition, the changeset cast, and the changeset validate_required. I would rather just write a map with all the data, and not worry about keeping everything synced

I’ve avoided writing macros until now, this might be an amusing learning experience.

What I want to do is something like this:

defmodule Player do
  use Ecto.Schema
  @required_fields_map %{email: :string}
  @optional_fields_map %{foo: :string}

  @required_fields_list  Map.keys(@required_fields_map)
  @optional_fields_list   Map.keys(@optional_fields_map)
  @all_fields_map         Map.merge(@optional_fields_map, @required_fields_map)
  @all_fields_list           @required_fields_list ++ @optional_fields_list

  # Some macro here to take @all_fields_map and transform it into:
  #schema "player" do
  #  field :email :string
  #  field :foo     :string
  #end

  changeset(player, params \\ %{}) do
    player
    |> Ecto.Changeset.cast(params, @all_fields_list)
    |> Ecto.Changeset.validate_required(@required_fields_list)
  end
end

Is it even possible to do this? Is there a better way?

I would say there are no real duplication, because no schema is really equal to another. Most prefer explicit over implicit…

Also your example does not deal with custom vaidations, nor associations.

I’m hardly a pro but this is what I do to reduce duplication.

Imagine having a user.ex file in a context:

  @required [:email, :username]

  def changeset_allowed(user, attrs, allowed) do
    user
    |> cast(attrs, allowed)
    |> validate_required(@required -- @required -- allowed)
    # TODO: add all of your validations for every field you plan to have, required or not.
  end

And then create separate changesets with specific allowed rights, such as:

  def admin_changeset(user, params \\ %{}) do
    allowed = [
      :admin?,
      :email,
      :username
    ]

    user
    |> changeset_allowed(params, allowed)
  end

  def email_address_changeset(user, params) do
    allowed = [:email]

    changeset_allowed(user, params, allowed)
  end

  def profile_changeset(user, params) do
    allowed = [:username]

    changeset_allowed(user, params, allowed)
  end

The basic idea is even if your changeset_allowed has validations for 50 fields, only fields that are marked required / allowed will have their validations run against it when you call the individual specific changesets (which you would call for specific forms).

Thanks for the replies!

To clarify, my issue is that I need to declare fields in both the schema, and the changeset.

The changeset is the easy part because it just accepts data as you illustrated above.
But schema won’t accept lists or maps.

schema "player" do
  field(:email, :string)
  ...
end

So is there a way to dynamically create a schema based on plain elixir data. e.g.

@my_schema %{email: %{t: :string, ...}}
schema "player" do
  Enum.reduce(@my_schema, fn ({k, v}) -> 
    field(k, v[:t])
  end)
end

I feel it’s better to just be explicit here. Your schema and changeset fields won’t always match (for example, computed fields) and if you find yourself in that situation, you will now be faced with adding more to your DSL or now having two ways to define schema and changesets in the same app.

I come from Ruby and was a bit put off by this at first myself but have come to see not see it as a burden at all.

1 Like

Macros get called during compilations stage, expanded, and the AST they return is being injected in the place of the call. There is no magic.

That said, if you want, you might make it as macroed as needed. E. g. somewhat along the following lines would work (not tested).

defmodule Macros do
  defmacro __using__(opts \\ []) do
    {name, opts} = Keyword.pop(opts, :name)
    all_fields = opts |> Keyword.values |> Keyword.merge()
    schema_def = # prepare to unquote in the resulting AST
      Enum.map(all_fields, fn {k, v} ->
        quote do: field unquote(k), unquote(v)
      end)

    quote do
      use Ecto.Schema

      Enum.each(unquote(opts), fn {k, v} ->
        Module.put_attribute(__MODULE__, k, Map.new(v))
      end)
      Module.put_attribute(__MODULE__, :all_fields, unquote(all_fields))
      
      schema unquote(name) do
        unquote(schema_def)
      end

      changeset(unquote(:"#{name}"), params \\ %{}) do
        unquote(:"#{name}")
        |> Ecto.Changeset.cast(params, @all_fields)
        |> Ecto.Changeset.validate_required(@required_fields)
      end
    end
  end
end

And use it as

defmodule Player do
  use Macros,
    name: "player",
    required_fields: [email: :string],
    optional_fields: [foo: :string]
end
3 Likes

Thanks! :smiley:

There’s a really great talk about macros for solving similar issues by Lizzie Paquette

It’s well worth a watch.

1 Like