Tutorial
How to use Phoenix forms for embedded schema and JSONB
This post was updated 27 Mar
In this tutorial, I want to show you how you can setup a form that stores data partly in the columns of a database but also some of the fields in JSONB with an embedded schema.
The reason you might want to do this instead of saving all entries in separate columns is that you can add more fields without changing the database.
STEP 1 - Generate a new column
In this example, I already have a CRUD interface set up for products in a table called shop_products. What I need to do is add a JSONB column to that table and I will give it the generic name data.
mix ecto.gen.migration add_data_column_to_products
The way to specify a JSONB column in Ecto is to add the type :map. The default is an empty map and I don’t want it to be blank.
defmodule MyApp.Repo.Migrations.AddDataToProducts do
use Ecto.Migration
def change do
alter table(:shop_products) do
add :data, :map, default: %{}, null: false
end
end
end
NOTE that I haven’t added an index because I don’t need one in this tutorial.
With the migration file in place, I need to run the migrations:
mix ecto.migrate
STEP 2 - Add a data schema
The data will be an embedded schema with its own changeset, and if needed, its own validation rules. In this example, I want to add size and colour as additional fields to the products.
# lib/my_app/shop/data.ex
defmodule MyApp.Shop.Data do
use Ecto.Schema
import Ecto.Changeset
@primary_key false
embedded_schema do
field :size, :string
field :color, :string
end
@doc false
def changeset(option, attrs) do
option
|> cast(attrs, [:size, :color])
|> validate_required([])
end
end
In the product schema, I need to make 3 changes.
- Alias the Data schema
- Add an
embeds_onefor the Data schema. Also addon_replace: :update - In the changeset, add the line
|> cast_embed(:data)
# lib/my_app/shop/product.ex
defmodule MyApp.Shop.Product do
use Ecto.Schema
import Ecto.Changeset
alias MyApp.Shop.Data # <-- ADD THIS LINE
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "shop_products" do
field :archived_at, :naive_datetime
field :name, :string
embeds_one :data, Data, on_replace: :update # <-- ADD THIS LINE
timestamps()
end
@doc false
def changeset(product, attrs) do
product
|> cast(attrs, [:name, :archived_at])
|> validate_required([:name])
|> cast_embed(:data) # <-- ADD THIS LINE
end
end
I can test this by putting the data as a key in the valid_attrs map. I want to test this in both create and update.
# test/my_app/shop_test.exs
test "create_product/1 with data attributes casts and updates embedded data" do
valid_attrs = Map.put(@valid_attrs, :data, %{size: "L", color: "green"})
assert {:ok, %Product{} = product} = Shop.create_product(valid_attrs)
assert product.data.size == "L"
assert product.data.color == "green"
update_attrs = Map.put(@valid_attrs, :data, %{size: "M", color: "red"})
assert {:ok, %Product{} = product} = Shop.update_product(product, update_attrs)
assert product.data.size == "M"
assert product.data.color == "red"
end
STEP 3 - Add embedded fields to the form
Since I have set up an embeds_one, and that behaves as a relation, I can use inputs_for to store the values in the data column.
When I start the app and test the form, it looks just like the other fields. If the fields don’t show up, it is most likely because the data field in the database is defaulted to NULL. Make sure it defaults to an empty map.
When that is saved, I can test this in the console by getting the last product and see that the data is there.
Shop.get_product! "3ab9f40e-b290-4a58-9311-c0d88a79eaf1"
=> %MyApp.Shop.Product{
__meta__: #Ecto.Schema.Metadata<:loaded, "shop_products">,
archived_at: nil,
data: %MyApp.Shop.Data{color: "Black", size: "L"},
id: "3ab9f40e-b290-4a58-9311-c0d88a79eaf1",
inserted_at: ~N[2021-06-01 08:19:21],
name: "Test",
updated_at: ~N[2021-06-01 11:13:56]
}