How to cast & validate map-typed Ecto schema field
Tags: elixir, ecto, tricks
Published: 2024-03-01 15:04:09

## Problem

Let's go straight to the problem. Suppose you have a `Post` schema, defined as follows:

```elixir
# lib/my_app/post.ex

defmodule MyApp.Post do
  use Ecto.Schema
  import Ecto.Changeset

  schema "posts" do
    field :title, :string
    field :content, :string
    field :location, :map
  end
end
```

We have a `location` field in `Post` schema, which is a `:map` type. `:map` is often used when you want to store a simple set of key-value pairs, but don't want to create a separate database table for it.   

But how do we cast and validate the `location` field? We can't use `Ecto.Changeset.cast/3` directly, because it only works with primitive types like `:string`, `:integer`, `:boolean`, etc. Of course we can check that the `location` field is a map, but how do we validate the keys and values of the map?   

## Solution: `Ecto.Changeset.cast_embed/3`

We can use `Ecto.Changeset.cast_embed/3` to cast and validate the `location` field. `cast_embed/3` is used to cast and validate embedded associations, but it can also be used to cast and validate map-typed fields.

Let's see how we can use `cast_embed/3` to cast and validate the `location` field. Suppose our location map has to follow these rules:
- It can only have keys: `:latitude`, `:longitude` and `:address`
- The `:latitude` and `:longitude` is required and should be a float
- The `:address` is optional and should be a string

Now we need to change something from the code above - we need to change the type of `location` field from `:map` to `Location` schema. Here's how we can define the `Location` schema and use `cast_embed/3` to cast and validate the `location` field:

```elixir
# lib/my_app/post.ex

defmodule MyApp.Post do
  use Ecto.Schema
  import Ecto.Changeset

  schema "posts" do
    field :title, :string
    field :content, :string

    embeds_one :location, Location do
      field :latitude, :float
      field :longitude, :float
      field :address, :string
    end
  end

  def changeset(post, attrs) do
    post
    |> cast(attrs, [:title, :content])
    |> cast_embed(:location, with: &location_changeset/2)
  end

  defp location_changeset(location, attrs) do
    location
    |> cast(attrs, [:latitude, :longitude, :address])
    |> validate_required([:latitude, :longitude])
  end
end
```

To use `cast_embed/3`, we need to define the `Location` schema and use `embeds_one/3` to define the embedded schema. We also need to define a `location_changeset/2` function to cast and validate the `location` field.  

For migration, you don't have to change anything. The migration will look like this:

```elixir
defmodule MyApp.Repo.Migrations.CreatePost do
  use Ecto.Migration

  def change do
    create table(:posts) do
      add :title, :string
      add :content, :string
      add :location, :map

      timestamps()
    end
  end
end
```

As you can see, we still use `:map` type for the `location` field in the migration. 

Now let's test this on `iex` by with some happy and sad paths (I've omitted the printed output for brevity):

```elixir
# Happy path
iex> post = %Post{}
iex> attrs = %{title: "Hello", content: "World", location: %{latitude: 1.23, longitude: 4.56, address: "123 Main St"}}
iex> changeset = Post.changeset(post, attrs)
iex> changeset.valid?
true

# Sad path - missing latitude
iex> post = %Post{}
iex> attrs = %{title: "Hello", content: "World", location: %{longitude: 4.56, address: "123 Main St"}}
iex> changeset = Post.changeset(post, attrs)
iex> changeset.valid?
false
iex> changeset.errors
[latitude: "can't be blank"]
```

## What if we want to make location type reusable?

If you want to make the `Location` type reusable, you can define it as a separate schema and use it in multiple schemas. Here's how you can define the `Location` schema and use it in the `Post` schema:

```elixir
# lib/my_app/location.ex

defmodule MyApp.Location do
  use Ecto.Schema
  import Ecto.Changeset

  embedded_schema do
    field(:latitude, :float)
    field(:longitude, :float)
    field(:address, :string)
  end

  @doc false
  def changeset(location, attrs) do
    location
    |> cast(attrs, [:latitude, :longitude, :address])
    |> validate_required([:latitude, :longitude])
  end
end

# lib/my_app/post.ex

defmodule MyApp.Post do
  use Ecto.Schema
  import Ecto.Changeset

  schema "posts" do
    field :title, :string
    field :content, :string
    embeds_one :location, MyApp.Location
  end

  def changeset(post, attrs) do
    post
    |> cast(attrs, [:title, :content])
    |> cast_embed(:location)
  end
end
```

As you can see, we no longer need to reference any `location_changeset/2` function in the `cast_embed/3` function. It will automatically use the `MyApp.Location.changeset/2` function to cast and validate the `location` field, which is pretty neat.

You can now reuse the `Location` schema in other schemas as well, for example in a `User` schema:

```elixir
# lib/my_app/user.ex

defmodule MyApp.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :name, :string
    embeds_one :location, MyApp.Location
  end

  def changeset(user, attrs) do
    user
    |> cast(attrs, [:name])
    |> cast_embed(:location, required: true)
  end
end
```

If you want to make location be a mandatory field, you can use `cast_embed/3` with `required: true` option like above. This will make sure that the `location` field is mandatory and will raise an error if it's missing.

## Summary

In this article, we learned how to cast and validate map-typed Ecto schema field using `Ecto.Changeset.cast_embed/3`. We also learned how to make the embedded schema reusable in multiple schemas. This is a very powerful feature of Ecto, and it can help you to keep your code DRY and maintainable.

To learn more about embedded schemas in Ecto, you can refer to the official Ecto documentation: [Ecto - Embedded schemas](https://hexdocs.pm/ecto/embedded-schemas.html#content).   

I hope you find this article helpful. If you have any questions or feedback, feel free to reach out to me. Happy coding! 🚀