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
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
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
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"]
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
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
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)
Great work!
Just I think it is necessary in the definition of the embedded schema to use
embedded_schema
instead:schema "locations"
: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.