Ecto, while commonly used as a database wrapper, can also serve as a powerful data validation tool thanks to its Ecto.Changeset.

Let’s say you have a list of movie data that you want to clean up and put into a nice %Movie{} struct that would consist of the following fields:

  • title, a string,
  • runtime, an integer representing the movie runtime in minutes,
  • cast, a list of strings for each actor name,
  • releases, a map of the release dates for different countries.

While we could use Ecto’s schema/2 macro to define this, we will instead use a simple struct¹ as we won’t actually be persisting this data to a database².

defmodule Movie do
  defstruct [:title, :runtime, :cast, :releases]

  @type_constraints %{
    title: :string,
    runtime: :integer,
    cast: {:array, :string},
    releases: {:map, :date}
  }
end

You can check the list of available types here, and if you can’t find what you need you can always define a custom type.

Now that we have defined our constraints, we want a function that’ll take a regular map, validate it and return a %Movie{} if the input data is valid, or an error if it isn’t.

The first thing we’ll do is filter out any field from the input map that isn’t part of our %Movie{} struct or that can’t be converted to the right type. The cast/4 function allows us to do just that:

cast(data, params, permitted, opts \\ [])

data is our container, so here we’ll be using an empty %Movie{} struct³. Since we’re not using schema/2 we’ll need to add the type constraints here too: {%Movie{}, @type_constraints}. params is the data we want to filter and validate, permitted is a list of the keys we want to allow in params. opts allows to pass empty_values: (for example you might want to set this to [[], “”] to make empty lists and empty strings nil) but we won’t be using it here. It defaults to empty_values: [""].

Let’s add a function to our Movie module to create a new %Movie{} from a map of params:

defmodule Movie do
  import Ecto.Changeset

  ...

  @allowed_keys Map.keys(@type_constraints) # We want to allow every key that has a type constraint

  def new(movie_data) do
    {%__MODULE__{}, @type_constraints}
    |> cast(movie_data, @allowed_keys)
  end
end

We can now test it and make sure it works as expected:

iex> Movie.new(%{title: "The Hitchhiker's Guide to the Galaxy", runtime: "109", cast: ["Zooey Deschanel", "Martin Freeman"], releases: %{"UK" => "2005-04-28", "KR" => "2005-08-26"}})
#Ecto.Changeset<
  action: nil,
  changes: %{
    cast: ["Zooey Deschanel", "Martin Freeman"],
    releases: %{},
    runtime: 109,
    title: "The Hitchhiker's Guide to the Galaxy"
  },
  errors: [],
  data: #Movie<>,
  valid?: true
>

Ecto uses %Changeset{} to track changes in the data, which is really helpful when you want to persist your data to a database using Ecto or to get a list of validation errors, but not really useful for valid data in our case. Thankfully, apply_changes/1 can help us with that:

defmodule Movie do
  ...

  def new(movie_data) do
    case changeset(movie_data) do
      %{valid?: true} = changeset -> {:ok, apply_changes(changeset)}
      changeset -> {:error, changeset}
    end
  end

  def changeset(params) do
    {%__MODULE__{}, @type_constraints}
    |> cast(params, @allowed_keys)
  end
end

The %Changeset{} struct has a valid? flag that we can use to check if our data is valid. apply_changes/1 will happily ignore it and return the underlying struct with our new data regardless of this flag⁴, so we have to check that it’s actually set to true before calling it. Let’s try that again:

iex> Movie.new(%{title: "The Hitchhiker's Guide to the Galaxy", runtime: "109", cast: ["Zooey Deschanel", "Martin Freeman"], releases: %{"UK" => "2005-04-28", "KR" => "2005-08-26"}})
{:ok,
 %Movie{
   cast: ["Zooey Deschanel", "Martin Freeman"],
   releases: %{"KR" => ∼D[2005-08-26], "UK" => ∼D[2005-04-28]},
   runtime: 109,
   title: "The Hitchhiker's Guide to the Galaxy"
 }}
iex> Movie.new(%{runtime: "not a number", releases: %{"UK" => "not a date"}})
{:error,
 #Ecto.Changeset<
   action: nil,
   changes: %{},
   errors: [
     releases: {"is invalid", [type: {:map, :date}, validation: :cast]},
     runtime: {"is invalid", [type: :integer, validation: :cast]}
   ],
   data: #Movie<>,
   valid?: false
 >}

As you can see, cast/3 took care of converting runtime to an integer and our release dates to %Date{} structs, returning a list of errors when some of the data was invalid.

Now that we have our new/1 function working, let’s add some validation!

First, we want to make sure our movies always have a title and a runtime, so we’ll use validate_required/3 to help us with that:

defmodule Movie do
  @required_keys [:title, :runtime]

  ...

  def changeset(params) do
    {%__MODULE__{}, @type_constraints}
    |> cast(params, @allowed_keys)
    |> validate_required(@required_keys)
  end
end

And that’s it! Ecto will return the changeset with valid? flag set to false if either of these two fields is missing in our data. There are a lot of validation functions in Ecto.Changeset to add constraints on all sorts of data. For example, we could use validate_number/3 to ensure the runtime is always greater than 30 minutes and shorter than 5 hours, or validate_length/3 to make sure there’s at least one actor in cast.

Sometimes, though, you’ll want to have validation rules that a specific to your use case. For this, Ecto provides an helper function validate_change/3 that we can use to define custom validations.

Let’s say we want to allow setting the IMDB page for each movie. Ecto doesn’t have a URL validator so we’re going to define a custom one and use it in our changeset/1 function:

defmodule Movie do
  ...

  defstruct [:title, :runtime, :cast, :releases, :imdb_url]

  @type_constraints %{
    ...
    imdb_url: :string
  }

  ...

  def changeset(params) do
    {%__MODULE__{}, @type_constraints}
    │> cast(params, @allowed_keys)
    │> validate_required(@required_keys)
    │> validate_imdb_url()
  end

  defp validate_imdb_url(changeset) do
    validate_change(changeset, :imdb_url, fn :imdb_url, value ->
      case URI.parse(value) do
        %URI{host: "imdb.com"} -> []
        %URI{host: "www.imdb.com"} -> []
        _ -> [imdb_url: "is not a valid URL"]
      end
    end)
  end
end

validate_change/3 expects a changeset, the name of the field you want to validate and a function that will be passed the name of the field and its value and that should return a list of validation errors if any.

Note that the custom validation function will only be called if there’s a change for the given field and if the new value isn’t nil. Since we have set the type of imdb_url to :string the value passed to our validation function will always be a string.

Let’s give it a try:

iex> Movie.new(%{title: "The Hitchhiker's Guide to the Galaxy", runtime: "109", cast: ["Zooey Deschanel", "Martin Freeman"], releases: %{"UK" => "2005-04-28", "KR" => "2005-08-26"}, imdb_url: "https://www.imdb.com/title/tt0371724"})
{:ok,
 %Movie{
   ...
 }}
iex> Movie.new(%{title: "The Hitchhiker's Guide to the Galaxy", runtime: "109", cast: ["Zooey Deschanel", "Martin Freeman"], releases: %{"UK" => "2005-04-28", "KR" => "2005-08-26"}, imdb_url: "https://www.not-imdb.com/"})
{:error,
 #Ecto.Changeset<
   action: nil,
   changes: %{
     cast: ["Zooey Deschanel", "Martin Freeman"],
     imdb_url: "https://www.not-imdb.com/",
     releases: %{"KR" => ∼D[2005-08-26], "UK" => ∼D[2005-04-28]},
     runtime: 109,
     title: "The Hitchhiker's Guide to the Galaxy"
   },
   errors: [imdb_url: {"is not a valid URL", []}],
   data: #Movie<>,
   valid?: false
 >}

Et voilà!

  1. Note that you don’t actually need to define a struct and could just use a map instead.
  2. schema/2 will add a __meta__ field that’s mostly here to help with data persistance.
  3. We could also pass a non-empty struct, which is nice if you need to update existing data.
  4. apply_action/2 would take care of this but requires an action to be given, and that doesn’t make much sense in our case. You would probably want to use it when working with Phoenix form, as they require an action to be set in order to display errors.

PS: Looking to join a collaborative remote engineering team writing Elixir and React? We’re often hiring, go check out our listings at https://packlane.com/jobs

Julien M. Author
Sorry! The Author has not filled his profile.
×
Julien M. Author
Sorry! The Author has not filled his profile.

Pin It on Pinterest

Share This