Embedded schemas in Ecto

3 minute read

I’m attempting a rewrite of our master-data management system in Elixir. The current system is a bunch of Python functions I wrote back in 2021 so that I interact with a REST API autogenerated by NocoDB backed by Postgres. My Python script allows me to GET and PUT records to tables such as ProductCategories, Brands, ProductVariants, ProductItems.

Making descriptions more adaptable

The table ProductItems contains the records of all issued sales item numbers; among other columns, each record contains a description for offers.

Initially, all descriptions were in Greek, in a column named DescriptionEL. For a few items quoted in English (for customers either abroad or in Greece, but communicating in English), I saved the description in a column named DescriptionEN.

For the rewrite I thought I’d approach this in a different way so that I don’t need a set of columns, one per language, for every table that contains multilingual strings.

One idea was to store the description within a field of type :map. For example, like so:

{
    "en": "Rotary Valves",
    "el": "Περιστροφικές Βαλβίδες"
}

Embedded Schemas

As it turns out, there is a better way to do this in Elixir and Ecto: embedded schemas.

Records in different tables require a description that could be multilingual, therefore it makes sense to not have te definition of the embedded resource be inline within each module’s schema, but to extract it into a separate resource.

# lib/app/portfolio/lang_term.ex

defmodule Bouketo.Portfolio.LangTerm do
  use Ecto.Schema
  import Ecto.Changeset
  alias Bouketo.Portfolio.LangTerm

  embedded_schema do
    field :lang, :string
    field :term, :string
  end

  @doc false
  def changeset(%LangTerm{} = langterm, attrs) do
    langterm
    |> cast(attrs, [:lang, :term])
    |> validate_required([:lang, :term])
  end
end

Correspondingly, the Category resource then embeds_many records of type App.Portfolio.LanguageTerm. We also extend the changeset function to cast_embed on the embedded resource. These are the changes to the file generated by phx.gen.html for Category:

# lib/app/portfolio/category.ex

defmodule App.Portfolio.Category do
  use Ecto.Schema
  import Ecto.Changeset
  alias App.Portfolio.LangTerm # add this line

  schema "categories" do
    field :code, :string

    embeds_many :names, LangTerm # add this line

    timestamps()
  end

  @doc false
  def changeset(category, attrs) do
    category
    |> cast(attrs, [:code])
    |> validate_required([:code])
    |> validate_length(:code, is: 3)
    |> cast_embed(:names) # add this line
  end
end

Migrations

After that we need to create a migration with mix ecto.gen.migration embed_languageterms, and then edit the file generated in priv/repo/migrations to create the :languageterms table and add the :names column to table :categories:

defmodule App.Repo.Migrations.Langterms_Embed do
  use Ecto.Migration

  def change do
    alter table(:categories) do
      add :names, :map
    end
  end
end

Trying it out in IEx

First, let’s see if we can create an empty category record to apply changesets to:

iex(1)> category = %Bouketo.Portfolio.Category{}
%Bouketo.Portfolio.Category{
  __meta__: #Ecto.Schema.Metadata<:built, "categories">,
  id: nil,
  code: nil,
  is_locked: false,
  is_top: false,
  names: [],
  inserted_at: nil,
  updated_at: nil
}

Next, we want to create a changeset so that we give this record a :code and :names. Note that we are passing the values of the LangTerm embedded schema as a map, because of add :names, :map in the migration file.

iex(3)> changeset = 
          category
          |> Bouketo.Portfolio.Category.changeset(
            %{code: "234", 
              names: [
                %{lang: "en", term: "Rotary Valves"}, 
                %{lang: "el", term: "Περιστροφικές Βαλβίδες"}
                ]})
#Ecto.Changeset<
  action: nil,
  changes: %{
    code: "234",
    names: [
      #Ecto.Changeset<
        action: :insert,
        changes: %{lang: "en", term: "Rotary Valves"},
        errors: [],
        data: #Bouketo.Portfolio.LangTerm<>,
        valid?: true
      >,
      #Ecto.Changeset<
        action: :insert,
        changes: %{
          lang: "el",
          term: "Περιστροφικές Βαλβίδες"
        },
        errors: [],
        data: #Bouketo.Portfolio.LangTerm<>,
        valid?: true
      >
    ]
  },
  errors: [],
  data: #Bouketo.Portfolio.Category<>,
  valid?: true
>

The changeset is valid (see valid?: true), which means we can pipe it to our repo’s insert_or_update!/2:

iex(6)> changeset |> Bouketo.Repo.insert_or_update!()
%Bouketo.Portfolio.Category{
  __meta__: #Ecto.Schema.Metadata<:loaded, "categories">,
  id: 1,
  code: "234",
  is_locked: false,
  is_top: false,
  names: [
    %Bouketo.Portfolio.LangTerm{
      id: "62d46a8b-427a-4e69-8d7c-26d69e8043bb",
      lang: "en",
      term: "Rotary Valves"
    },
    %Bouketo.Portfolio.LangTerm{
      id: "db281772-bb77-4e70-b14a-ff204e5c3086",
      lang: "en",
      term: "Περιστροφικές Βαλβίδες"
    }
  ],
  inserted_at: ~N[2023-02-18 09:31:25],
  updated_at: ~N[2023-02-18 09:31:25]
}

Notice that the changeset is now valid (valid?: true).