‹ Elliot Jackson

How to Write Custom Validations for Ecto Changesets

Jan 10, 2021

Written for Phoenix 1.5.7 & Ecto 3.5.5

Ecto changesets provide common validation options but we can also write our own. Before we get into writing our own though, we need to understand how (most of) the default validators work. Let’s have a look at the definition of validate_inclusion for example:

def validate_inclusion(changeset, field, data, opts \\ []) do
  validate_change changeset, field, {:inclusion, data}, fn _, value ->
    if value in data,
      do: [],
      else: [{field, {message(opts, "is invalid"), [validation: :inclusion, enum: data]}}]
  end
end

We can see that the actual logic is wrapped inside validate_change/4. validate_change/3 and validate_change/4 both take a changeset, a field, and a validator function (validate_change/4 takes metadata as its 3rd paramater). validate_change will then check for the presence of the field in the changeset and if it exists and the change value is not nil then it will run the validator. The validator must return either an empty list if the validation passed or a list of errors which will be appended to the :errors field of the changeset and valid? will be set to false.

With that in mind, let’s write our own validation. Below we have a basic module with a schema and a changeset function for our tutorials table:

defmodule MyApp.Tutorials.Tutorial do
  use Ecto.Schema
  import Ecto.Changeset

  schema "tutorials" do
    field :name, :string
    field :is_useful, :boolean

    timestamps()
  end

  @doc false
  def changeset(tutorial, attrs) do
    tutorial
    |> cast(attrs, [:name, :is_useful])
    |> validate_required([:name, :is_useful])
  end
end

To add a custom validation, we’ll write a function that takes the Changeset as its first parameter and then the field atom:

defmodule MyApp.Tutorials.Tutorial do
  
  # ...

  def validate_is_useful(changeset, field) when is_atom(field) do
    validate_change(changeset, field, fn field, value ->
      case value do
        true ->
          []

        false ->
          [{field, "tutorials should be useful"}]
      end
    end)
  end
end

All we need to do now is update our changeset function to include the new validation:

defmodule MyApp.Tutorials.Tutorial do
  
  # ...

  @doc false
  def changeset(tutorial, attrs) do
    tutorial
    |> cast(attrs, [:name, :is_useful])
    |> validate_required([:name, :is_useful])
    |> validate_is_useful(:is_useful)
  end

  # ...

end

You could also write a custom validation by simply getting the field value you want from the changeset using get_field/3, and that does have its palce, but by leveraging validate_change when the field you’re validating exists and has a value your validator will be run but if you’re generating a changeset with no changes, for example to pass to a form in Phoenix, then the validator won’t run and raise an error when the field who’s value you’re trying to get doesn’t exist.

That’s all there is to it really! For further reading, check out the Ecto.Changeset docs, they’re very good. If you have any questions, don’t hesitate to ask on dev.to 💬