DEV Community

Hoon Wee
Hoon Wee

Posted on • Updated on

How to cast & validate map-typed Ecto schema field

Problem

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

# 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
Enter fullscreen mode Exit fullscreen mode

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:

# 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
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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):

# 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"]
Enter fullscreen mode Exit fullscreen mode

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:

# 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
Enter fullscreen mode Exit fullscreen mode

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:

# 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
Enter fullscreen mode Exit fullscreen mode

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.

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

Top comments (2)

Collapse
 
hackvan profile image
Diego Camacho

Great work!

Just I think it is necessary in the definition of the embedded schema to use embedded_schema instead :schema "locations":

defmodule MyApp.Location do
...
embedded_schema do
  field(:latitude, :float)
  field(:longitude, :float)
  field(:address, :string)
end
...
Enter fullscreen mode Exit fullscreen mode
Collapse
 
hoonweedev profile image
Hoon Wee

Thanks for pointing that out 👍

Actually the code will still work, but apparently your solution is actually more conventional way to do this (as mentioned in Ecto docs) - I'm editing the post with your solution.