Tutorial

View on Github

How to use Phoenix forms for embedded schema and JSONB

jsonbformsecto

In this tutorial, I want to show you how you can setup form that stores the data in partly the columns in 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 setup a crud interface for products in a table called shop_products. What I need to to is to add a JSONB coulmn 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 dont want it to be blank.

defmodule Tutorial.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 dont added an index because I dont need one in this tutorial.

With the migration file in place, I need to run the migrations by:

mix ecto.migrate

STEP 2 - Add a data schema

The data will be en 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/tutorial/shop/data.ex
defmodule Tutorial.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.

  1. alias the Data schema
  2. and an embeds_one for the Data shema. Also add the on_replace: :update
  3. in the changeset, add the line |> cast_embed(:data)
# lib/tutorial/shop/product.ex
defmodule Tutorial.Shop.Product do
  use Ecto.Schema
  import Ecto.Changeset

  alias Tutorial.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 valet_attrs map. I want to test this in both create and update.

# test/tutorial/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 setup an embeds_one, and that behaves as a relation, I can use fields_for to store the values in the data column.

<!-- lib/tutorial_web/live/admin/product_live/form_component.html.leex -->
<h2 class="mb-4 text-2xl font-semibold text-gray-700"><%= @title %></h2>

<%= f = form_for @changeset, "#",
  id: "product-form",
  phx_target: @myself,
  phx_change: "validate",
  phx_submit: "save" %>

  <div class="mb-4">
    <%= label f, :name %>
    <%= text_input f, :name, class: "form-control" %>
    <%= error_tag f, :name %>
  </div>
  
  <!-- Add this -->
  <%= inputs_for f, :data, fn ff -> %>
    <div class="mb-4">
      <%= label ff, :size %>
      <%= text_input ff, :size, class: "form-control" %>
      <%= error_tag ff, :size %>
    </div>
    <div class="mb-4">
      <%= label ff, :color %>
      <%= text_input ff, :color, class: "form-control" %>
      <%= error_tag ff, :color %>
    </div>
  <% end %>

  <div class="mt-6">
    <%= submit "Save", class: "btn btn-primary", phx_disable_with: "Saving..." %>
  </div>
</form>

When I start the app, and test the form, it looks just like the other fields. If the fields dont show up, it is most likely because the data field in the database is defaulted to NULL. Make sure that 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"
=> %Tutorial.Shop.Product{
  __meta__: #Ecto.Schema.Metadata<:loaded, "shop_products">,
  archived_at: nil,
  data: %Tutorial.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]
}

Phoenix Boilerplate

Generate a Phoenix Boilerplate and save hours on your next project.

Try now

SAAS Starter Kit

Get started and save time and resources by using the SAAS Starter Kit built with Phoenix and LiveView.

Learn More

Related Tutorials

Published 10 Feb - 2020 - Updated 01 May - 2020

Add an use a JSONB field in Phoenix and Ecto

ectojsonbphoenixpostgresql
PostgreSQL has native support for objects stored as JSON as actually binary JSON (or JSONB). With JSONB format, you can add index do the column for ..

Published 13 May

How to create a custom select with Alpine JS and Phoenix LiveView

formsalpinejsliveview
In this tutorial, I want to go through how to build a custom select field that is used in Tailwind UI. And I will build it with Alpine JS and Phoeni..