Skip to content

Instantly share code, notes, and snippets.

@andreyuhai
Last active July 30, 2021 22:38
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save andreyuhai/a919861bd4f1e6eea0a661ae14f97922 to your computer and use it in GitHub Desktop.
Save andreyuhai/a919861bd4f1e6eea0a661ae14f97922 to your computer and use it in GitHub Desktop.
Pizzapp Snippets
<%= form_for @changeset, @action, fn f -> %>
<h3>Type</h3>
<%= select f, :type, @pizza_types %>
<%= error_tag f, :type %>
<div class="form-group">
<h3>Extras</h3>
<%= for extras <- inputs_for(f, :extras) do %>
<%= for extra <- @extras do %>
<div>
<%= label(extras, extra) %>
<%= checkbox(extras, extra) %>
</div>
<% end %>
<% end %>
</div>
<%= submit "Order" %>
<% end %>
defmodule Pizzapp.Repo.Migrations.AddOrdersTable do
use Ecto.Migration
def change do
create table("orders") do
add :type, :integer
add :extras, :map
end
end
end
defmodule Pizzapp.Orders.Extras do
alias Pizzapp.Orders.Order
import Ecto.Changeset
use Ecto.Schema
embedded_schema do
field :olives, :boolean
field :spinach, :boolean
field :onion, :boolean
field :extra_mozarella, :boolean
field :chicken, :boolean
field :pepperoni, :boolean
end
def changeset(%__MODULE__{} = extras, attrs \\ %{}) do
extras
|> cast(attrs, Order.extras())
end
end

Embedded schemas in Elixir

A little backstory

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.

What's an embedded schema?

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.

Programming Ecto

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!

Embedded schemas for persisting data

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.

DB schemas

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.


Creating a DB Migration for 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.

Ecto Schema - The map type

Schema for an Order

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.

<script src="https://gist.github.com/andreyuhai/a919861bd4f1e6eea0a661ae14f97922.js?file=order_schema_partial.ex"></script>

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.

Embedded Schema for Pizza Extras

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.

<script src="https://gist.github.com/andreyuhai/a919861bd4f1e6eea0a661ae14f97922.js?file=extras.ex"></script>

Creating the Form for Embedded Schema

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.

Form screenshot

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.

defmodule Pizzapp.Orders.Order do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
@pizza_types [
"Pizza Margherita": 1,
"Neapolitan Pizza": 2,
"Chicago Pizza": 3,
"New York Pizza": 4
]
@extras [
:olives,
:spinach,
:onion,
:extra_mozarella,
:chicken,
:pepperoni,
]
schema "orders" do
embeds_one :extras, Pizzapp.Orders.Extras, on_replace: :delete
field :type, :integer
end
def changeset(%__MODULE__{} = order, attrs) do
order
|> cast(attrs, [:type])
|> cast_embed(:extras)
|> validate_required([:type, :extras])
|> validate_inclusion(:type, 1..4)
end
def extras, do: @extras
def pizza_types, do: @pizza_types
end
defmodule Pizzapp.Orders.Order do
use Ecto.Schema
import Ecto.Changeset
schema "orders" do
embeds_one :extras, Pizzapp.Orders.Extras, on_replace: :delete
field :type, :integer
end
def changeset(%__MODULE__{} = order, attrs) do
order
|> cast(attrs, [:type])
|> cast_embed(:extras)
|> validate_required([:type, :extras])
|> validate_inclusion(:type, 1..4)
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment