A while ago, looking for a solution for adding subcategory visibility settings to our projects
table in a project, I stumbled upon embedded schemas. What we were trying to do is, basically, create a nested form consisting of checkboxes of different subcategories, so the user could select which subcategories to show and which ones to hide.
We would always need to fetch subcategory visibility options while loading in a project
row, so creating a separate table with an association to the projects
table wouldn't really make sense, hence we went with an embedded schema. This way it's also easier to change subcategory names. Since there's no table we would need to make changes only to our embedded schema fields.
Here we will go through the same process with a different example and explain you when, how and where you should use embedded schemas.
Embedded schema is an Ecto.Schema
type which you can embed into other schemas, hence they do not require a table in the DB. Unless you explicitly save them into a column in a parent schema with a table, they are only in memory thus you can use embedded schemas for casting and validating data outside the DB as well.
With embedded schemas, associated records are stored in a single database column along with the rest of the parent record’s values. When you load the parent record, the child records come right along with it. The implementation of this feature varies between databases. With PostgreSQL, Ecto uses the jsonb column type to store the records as an array of key/value pairs. For MySQL, Ecto converts the records into a JSON string and stores them as text. The end result, however, is the same: the embedded records are loaded into the appropriate Elixir structs and are available in a single query without having to call preload.
You might want to use embedded schemas when the parent record always requires an association to be loaded. Since the embeds are saved into the same row as the parent, you will be avoiding making extra calls to the DB to preload
associations or join tables which might give you a performance boost in return.
Another example of when you might want to use embedded schemas is if the structure of your schema is subject to frequent change that it doesn't make sense to create migrations all the time. You can basically change the fields in your embedded schema, make appropriate changes in your templates and Bob's your uncle!
To demonstrate you an example of when you would want to use embedded schemas, I've created a simple Phoenix application with a Postgres DB to get pizza orders of our customers. You can check out the repo at @andreyuhai/pizzapp. This is an SPA in the sense that it literally has only one page where you submit your pizza order using a form. :D
In our application, we have have an orders
table and each pizza order will have a type
and extras
columns. We're going to use an embedded schema for extras
column in our table in Postgres which will be of type jsonb
. We will use this column to keep the extras for a pizza order in JSON, which will map to a Map
in our application.
Another approach would be to create a table of extras
to keep the extras for pizzas, and create an order_extras
table to map the many-to-many relationship between orders
and extras
. You can see the DB schema below.
I'll only share the necessary code snippets related to embedded schemas to stay on topic. First off, we are going to start with the migration to create the orders
table.
<script src="https://gist.github.com/andreyuhai/a919861bd4f1e6eea0a661ae14f97922.js?file=add_orders_table.exs"></script>
On line 7 in the file above, you can see that we've defined extras
field as a map which will be a jsonb
in Postgres.
In order to support maps, different databases may employ different techniques. For example, PostgreSQL will store those values in jsonb fields, allowing you to just query parts of it. MSSQL, on the other hand, does not yet provide a JSON type, so the value will be stored in a text field.
We've got our migration, now we need to define our schemas and embedded schemas to be used. First we are going to create a schema for our orders
table.
As you see above between lines 5 and 8, creating our schema for an Order
is as simple as that. Pay attention to the line 6 though, where embeds_one/3
macro is used instead of field/3
. You might be tempted to use the field/3
macro there as well, in which case Phoenix will raise an ArgumentError
when you try to call inputs_for/2
for extras
field, which I will show you after implementing the order form for the frontend.
You should also pay attention to our changeset, especially the line 13 where we used cast_embed/3
function whereas we'd have used cast_assoc/3
function if this was an association. The rest is basic changeset creation and validation of unnecessary details.
Below you can see the code for our embedded schema Extras
. Creating an embedded schema is not really different than creating a normal schema. The only difference is, since embedded schemas do not have a table you just use the embedded_schema/1
macro and give it a list of fields that you want your embedded schema to have. We have just listed the fields as boolean
because we will have checkboxes with these fields on our form for users to pick extras from and depending on their choices we will mark these fields either true
or false
.
We are done with the backend part, assuming that a controller was created and related actions were implemented we can move on to our form partial which you can see below.
<script src="https://gist.github.com/andreyuhai/a919861bd4f1e6eea0a661ae14f97922.js?file=_pizza_form.html.eex"></script>The most important part in the snippet above is between the lines 8 and 15. On line 8 we are invoking inputs_for(f, :extras)
which basically returns a list with a HTML.Form
struct for pizza extras which you can see below.
pry(2)> inputs_for(f, :extras)
[
%Phoenix.HTML.Form{
action: nil,
data: %Pizzapp.Orders.Extras{
chicken: nil,
extra_mozarella: nil,
id: nil,
olives: nil,
onion: nil,
pepperoni: nil,
spinach: nil
},
errors: [],
hidden: [],
id: "order_extras",
impl: Phoenix.HTML.FormData.Ecto.Changeset,
index: nil,
name: "order[extras]",
options: [],
params: %{},
source: #Ecto.Changeset<action: nil, changes: %{}, errors: [],
data: #Pizzapp.Orders.Extras<>, valid?: true>
}
]
We pass this list to the for loop to be able to refer to the new form struct inside the for loop where we create checkboxes for each extra, that's exactly what we do between lines 9 and 14.
Here's a screenshot of how our form should look like with the help of simple CSS of course.
After you pick a pizza type, choose a few extras and submit the form you can see our order inserted into the DB which I've copied below.
id type extras
8 4 {"id": "e3664f2c-2454-4a6b-8565-c0bc1db7e720", "onion": true, "olives": true, "chicken": false, "spinach": true, "pepperoni": false, "extra_mozarella": false}
As you see, the extras we've picked have true
as their values in our JSON object and the others have false
. One more thing to note is that our JSON object has an id
key. Like normal schemas, embedded schemas have a primary key as well which is of type :binary_id
. The reason for that is clearly explained in Programming Ecto book.
The other difference is that the default type for the primary key is binary_id instead of id. binary_id’s are represented by an automatically generated UUID, rather than auto-incrementing integers. This is because auto-incrementing integers can only be declared on the column level, not for data inside a column. — Programming Ecto
To disable the creation of a primary key for a embedded schema you just need to set @primary_key
to false
above your embedded schema definition.