Skip to content

Commit ec068e1

Browse files
authored
Embedded Schemas improvements (#3799)
1 parent b0d839b commit ec068e1

File tree

1 file changed

+114
-52
lines changed

1 file changed

+114
-52
lines changed
Lines changed: 114 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,89 +1,84 @@
11
# Embedded Schemas
22

3-
Embedded schemas are a feature in Ecto that allows you to define and validate structured data. This data can reside in-memory through structs or, with a migration, be stored in a database field. Embedded schemas work with maps and arrays.
3+
Embedded schemas allow you to define and validate structured data. This data can live in memory, or can be stored in the database. Some use cases for embedded schemas include:
44

5-
They're great for:
5+
- You are maintaining intermediate-state data, like when UI form fields map onto multiple tables in a database.
66

7-
- Storing additional data about an entity without modifying your schema.
8-
- Storing additional data about an entity without tracking foreign key/many-to-many relationships
9-
- Validating arbitrary data structures
7+
- You are working within a persisted parent schema and you want to embed data that is...
108

11-
Example use cases:
9+
- simple, like a map of user preferences inside a User schema.
10+
- changes often, like a list of product images with associated structured data inside a Product schema.
11+
- requires complex tracking and validation, like an Address schema inside a User schema.
1212

13-
- User profiles: storing additional information like profile pictures, settings
14-
- Shop products: storing additional product images
13+
- You are using a document storage database and you want to interact with and manipulate embedded documents.
1514

16-
While embedded schemas require an initial migration to create the field, any subsequent modifications to the data structure don't.
15+
## User Profile Example
1716

18-
This feature is great for situations where you'd like to store data associated to a specific entity, but a relation or new table would be overkill. You can even write changesets to perform validations on this data! The downside, however, is as you change the embedded schema overtime, early data won't have fields that you added later on.
19-
20-
## Example
21-
22-
An example of embedded schemas is to store additional information about the current user. The finished example for this guide's embedded schema will look like this:
17+
Let's explore an example where we have a User and want to store "profile" information about them. The data we want to store is UI-dependent information which is likely to change over time alongside changes in the UI. Also, this data is not necessarily important enough to warrant new User `field`s in the User schema, as it is not data that is fundamental to the User. An embedded schema is a good solution for this kind of data.
2318

2419
```elixir
2520
defmodule User do
2621
use Ecto.Schema
2722

2823
schema "users" do
29-
field :is_active, :boolean
24+
field :full_name, :string
3025
field :email, :string
26+
field :avatar_url, :string
3127
field :confirmed_at, :naive_datetime
3228

33-
embeds_one "profile" do
34-
field :age, :integer
35-
field :favorite_color, Ecto.Enum, values: [:red, :green, :blue, :pink, :black, :orange]
36-
field :avatar_url, :string
29+
embeds_one :profile do
30+
field :online, :boolean
31+
field :dark_mode, :boolean
32+
field :visibility, Ecto.Enum, values: [:public, :private, :friends_only]
3733
end
3834

3935
timestamps()
4036
end
4137
end
4238
```
4339

44-
Let's kick things off with a tutorial to explain how to recreate this use case: building a solution for user profiles in an example app.
45-
46-
### Writing the schema
47-
48-
The first step is to write the structure of the data we're storing. In the case of our profile, we'd like to store the user's age, favorite color, and a profile picture.
40+
### Embeds
4941

50-
To begin writing this embedded schema, we must first think about what structure we want. Do we want to store an array of structures (using `embeds_many`) or just one (using `embeds_one`)?
51-
52-
In this case, every user should have only one profile associated to them, so we'll begin by writing like any other Ecto schema:
42+
There are two ways to represent embedded data within a schema, `embeds_many`, which creates a list of embeds, and `embeds_one`, which creates only a single instance of the embed. Your choice here affects the behavior of embed-specific functions like `Ecto.Repo.put_embed/4` and `Ecto.Repo.cast_embed/4`, so choose whichever is most appropriate to your use case. In our example we are going to use `embeds_one` since users will only ever have one profile associated with them.
5343

5444
```elixir
5545
defmodule User do
5646
use Ecto.Schema
5747

5848
schema "users" do
59-
field :is_active, :boolean
49+
field :full_name, :string
6050
field :email, :string
51+
field :avatar_url, :string
6152
field :confirmed_at, :naive_datetime
6253

6354
embeds_one :profile do
64-
field :age, :integer
65-
field :favorite_color, Ecto.Enum, values: [:red, :green, :blue, :pink, :black, :orange]
66-
field :avatar_url, :string
55+
field :online, :boolean
56+
field :dark_mode, :boolean
57+
field :visibility, Ecto.Enum, values: [:public, :private, :friends_only]
6758
end
6859

6960
timestamps()
7061
end
7162
end
7263
```
7364

74-
We can, however, clean this up a little. You can separate the profile to a distinct module and keep the `User` module tidy:
65+
### Extracting the embeds
66+
67+
While the above User schema is simple and sufficient, we might want to work independently with the embedded profile struct. For example, if there was a lot of functionality devoted solely to manipulating the profile data, we'd want to consider extracting the embedded schema into its own module.
7568

7669
```elixir
7770
# user/user.ex
7871
defmodule User do
7972
use Ecto.Schema
8073

8174
schema "users" do
82-
field :is_active, :boolean
75+
field :full_name, :string
8376
field :email, :string
77+
field :avatar_url, :string
8478
field :confirmed_at, :naive_datetime
8579

8680
embeds_one :profile, UserProfile
81+
8782
timestamps()
8883
end
8984
end
@@ -92,57 +87,124 @@ end
9287
defmodule UserProfile do
9388
use Ecto.Schema
9489

95-
embedded_schema "profile" do
96-
field :age, :integer
97-
field :favorite_color, Ecto.Enum, values: [:red, :green, :blue, :pink, :black, :orange]
98-
field :avatar_url, :string
90+
embedded_schema do
91+
field :online, :boolean
92+
field :dark_mode, :boolean
93+
field :visibility, Ecto.Enum, values: [:public, :private, :friends_only]
9994
end
10095
end
10196
```
10297

103-
Ta-da! Neat. Note we replaced the `embeds_one` macro by `embedded_schema`: `embeds_one` and `embeds_many` function like fields, similar to relations like `has_many`.
98+
It is important to remember that `embedded_schema` has many use cases independent of `embeds_one` and `embeds_many`. You can think of them as a persistence agnostic `schema`. This makes embedded schemas ideal for scenarios where you want to manage structured data without necessarily persisting it. For example, if you want to build a contact form, you still want to parse and validate the data, but the data is likely not persisted anywhere. Instead, it is used to send an email. Embedded schemas would be a good fit for such use case.
10499

105-
### Writing the migration
100+
### Migrations
106101

107-
To save this embedded schema to a database, we need to write a corresponding migration. Depending on whether you chose `embeds_one` or `embeds_many`, you must choose the corresponding `map` or `array` data type.
108-
109-
We used `embeds_one`, so the migration should have a type of `map`.
102+
If you wish to save your embedded schema to the database, you need to write a migration to include the embedded data.
110103

111104
```elixir
112105
alter table("users") do
113106
add :profile, :map
114107
end
115108
```
116109

117-
### Using changesets
110+
Whether you use `embeds_one` or `embeds_many`, it is recommended to use the `:map` data type (although `{:array, :map}` will work with `embeds_many` as well). The reason is that typical relational databases are likely to represent a `:map` as JSON (or JSONB in Postgres), allowing Ecto adapter libraries more flexibility over how to efficiently store the data.
111+
112+
### Changesets
118113

119-
When it comes to validation, you can define a changeset function for each module. For example, the module may say that both `age` and `favorite_color` fields are required:
114+
Changeset functionality for embeds will allow you to enforce arbitrary validations on the data. You can define a changeset function for each module. For example, the UserProfile module could require the `online` and `visibility` fields to be present when generating a changeset.
120115

121116
```elixir
122117
defmodule UserProfile do
123118
# ...
124119

125-
def changeset(profile, attrs \\ %{}) do
120+
def changeset(%UserProfile{} = profile, attrs \\ %{}) do
126121
profile
127-
|> cast(attrs, [:age, :favorite_color, :avatar_url])
128-
|> validate_required([:age, :favorite_color])
122+
|> cast(attrs, [:online, :dark_mode, :visibility])
123+
|> validate_required([:online, :visibility])
129124
end
130125
end
126+
127+
profile = %UserProfile{}
128+
UserProfile.changeset(profile, %{online: true, visibility: :public})
131129
```
132130

133-
On the user side, you also define a `changeset/2` function, and then you use `cast_embed/3` to invoke the `UserProfile` changeset:
131+
Meanwhile, the User changeset function can require its own validations without worrying about the details of the UserProfile changes because it can pass that responsibility to UserProfile via `cast_embed/3`. A validation failure in an embed will cause the parent changeset to be invalid, even if the parent changeset itself had no errors.
134132

135133
```elixir
136134
defmodule User do
137-
use Ecto.Schema
138-
139135
# ...
140136

141137
def changeset(user, attrs \\ %{}) do
138+
user
139+
|> cast(attrs, [:full_name, :email, :avatar_url])
140+
|> cast_embed(:profile, required: true)
141+
end
142+
end
143+
144+
changeset = User.changeset(%User{}, %{profile: %{online: true}})
145+
changeset.valid? # => false; "visibility can't be blank"
146+
changeset = User.changeset(%User{}, %{profile: %{online: true, visibility: :public}})
147+
changeset.valid? # => true
148+
```
149+
150+
In situations where you have kept the embedded schema within the parent module, e.g., you have not extracted a UserProfile, you can still have custom changeset functions for the embedded data within the parent schema.
151+
152+
```elixir
153+
defmodule User do
154+
use Ecto.Schema
155+
156+
schema "users" do
157+
field :full_name, :string
158+
field :email, :string
159+
field :avatar_url, :string
160+
field :confirmed_at, :naive_datetime
161+
162+
embeds_one :profile, Profile do
163+
field :online, :boolean
164+
field :dark_mode, :boolean
165+
field :visibility, Ecto.Enum, values: [:public, :private, :friends_only]
166+
end
167+
168+
timestamps()
169+
end
170+
171+
def changeset(%User{} = user, attrs \\ %{}) do
142172
user
143173
|> cast(attrs, [:full_name, :email])
144-
# By default it calls UserProfile.changeset/2, pass the :with option to change it
145-
|> cast_embed(:user_profile, required: true)
174+
|> cast_embed(:profile, required: true, with: &profile_changeset/2)
175+
end
176+
177+
def profile_changeset(profile, attrs \\ %{}) do
178+
profile
179+
|> cast(attrs, [:online, :dark_mode, :visibility])
180+
|> validate_required([:online, :visibility])
146181
end
147182
end
183+
184+
changeset = User.changeset(%User{}, %{profile: %{online: true, visibility: :public}})
185+
changeset.valid? # => true
148186
```
187+
188+
### Querying embedded data
189+
190+
Once you have written embedded data to the database, you can use it in queries on the parent schema.
191+
192+
```elixir
193+
user_changeset = User.changeset(%User{}, %{profile: %{online: true, visibility: :public}})
194+
{:ok, _user} = Repo.insert(user_changeset)
195+
196+
(Ecto.Query.from u in User, select: {u.profile["online"], u.profile["visibility"]}) |> Repo.one
197+
# => {true, "public"}
198+
199+
(Ecto.Query.from u in User, select: u.profile, where: u.profile["visibility"] == :public) |> Repo.all
200+
# => [
201+
# %UserProfile{
202+
# id: "...",
203+
# online: true,
204+
# dark_mode: nil,
205+
# visibility: :public
206+
# }
207+
#]
208+
```
209+
210+
In databases where `:map`s are stored as JSONB (like Postgres), Ecto constructs the appropriate jsonpath queries for you. More examples of embedded schema queries are documented in [`json_extract_path/2`](https://hexdocs.pm/ecto/Ecto.Query.API.html#json_extract_path/2).

0 commit comments

Comments
 (0)