Embedding Elixir Structs in Ecto Models

Josh Steiner

Postgres can store unstructured data such as arrays, json, and jsonb as of version 9.4. Ecto, Elixir’s database wrapper, provides first class support for serializing and deserializing Ecto structs and arrays into these native data types without sacrificing the expressiveness of Ecto models. Embedded records have all the things regular models have, such as structured fields, lifecycle callbacks, and changesets. Let’s look at how easy it is to embed structs into our Ecto models.

Embedding a single struct with embeds_one

You can use embeds_one to embed a single struct in an Ecto model. The record you are embedding into must have a database field using the :map type for unstructured data. In Postgres this is the jsonb type under the hood.

defmodule MyApp.Repo.Migrations.CreateAccount do
  use Ecto.Migration

  def change do
    alter table(:accounts) do
      add :name, :string
      add :settings, :map
    end
  end
end

Now you can define the model you are embedding into:

defmodule Account do
  use Ecto.Model
  schema "accounts" do
    field :name
    embeds_one :settings, Settings
  end
end

And now, the define the record you have embedded in Account:

defmodule Settings do
  use Ecto.Model

  # embedded_schema is short for:
  #
  #   @primary_key {:id, :binary_id, autogenerate: true}
  #   schema "embedded Item" do
  #
  embedded_schema do
    field :email_signature
    field :send_emails, :boolean
  end
end

Embedded records behave like typical associations, except setting and deleting embeds can only be done via changesets.

account = Repo.get!(Account, 20)
settings = %Settings{email_signature: "Josh Steiner", send_emails: true}

# You may want to move this to the model layer.
# This is done here for ease of demonstration.
changeset =
  account
  |> Ecto.Changeset.change
  |> Ecto.Changeset.put_change(:settings, settings)

Repo.update!(changeset)

This will automatically call the function changeset/2 in the embedded model (in this case, Settings) when saving the parent record. This means embedded records automatically go through validations! You can modify the function that is called by passing the :on_cast option to embeds_one.

Embedded records are conveniently loaded with the parent record, so you don’t have to worry about joins or preloading:

account = Repo.get!(Account, 20)
account.settings #=> %Settings{...}

Embedding multiple structs with embeds_many

embeds_many behaves similarly to embeds_one, but allows you to store an array of Ecto structs. Under the hood, Postgres uses a combination of array columns with jsonb elements.

defmodule MyApp.Repo.Migrations.CreatePeople do
  use Ecto.Migration

  def change do
    alter table(:people) do
      add :name, :string

      # It is recommended to set the default value to an empty array.
      add :addresses, {:array, :map}, default: []
    end
  end
end

Now you can define your models:

defmodule Person do
  use Ecto.Model

  schema "people" do
    field :name
    embeds_many :addresses, Address
  end
end

defmodule Address do
  use Ecto.Model

  embedded_schema do
    field :street_name
    field :city
    field :state
    field :zip_code
  end
end

Setting many to many fields is done with an array:

person = Repo.get!(Person, 7)
addresses = [
  %Address{street_name: "20 Foobar Street", city: "Boston", state: "MA", zip_code: "02111"},
  %Address{street_name: "1 Finite Loop", city: "Cupertino", state: "CA", %zip_code: "95014"},
]

changeset =
  person
  |> Ecto.Changeset.change
  |> Ecto.Changeset.put_change(:addresses, addresses)

Repo.update!(changeset)

You can now access these like a has_many:

person = Repo.get!(Person, 5)
person.addresses #=> [%Address{...}, %Address{...}]

Trade-Offs

As you’ve seen, embedding records is simple and comes with many of the powerful features of Ecto. It’s even easy to add fields to an embedded record without running migrations. These are some nice benefits, however they come with serious trade-offs worth considering.

When you use unstructured data, you lose some of the powerful relational features that a SQL database provides. For example, since a record can only be embedded in a single parent, you can’t model a many-to-many relationship with embedded records. You also can’t use database constraints on structure and uniqueness when storing in a JSON field. While you can add these constraints to your application code, it’s usually best to validate at the database level to ensure data integrity.