Embedded schemas in Ecto
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
).