Evil Ecto Embeds

Beware the reaction when mixing jsonb queries with embedded schema defaults.

I jest. I ❤️ Ecto, but embeds and default values can be tricky. Yesterday, I had to fix an issue in a project where notifications where not being sent to all users. The problem lies in the combination of Ecto embeds_one, Ecto field defaults and database defaults.

I found the issue somewhat intriguing, so I wanted to 1) share a not-obviously-broken example, 2) explain what is wrong and 3) how to fix it.

The Pledge: Here's an example

Give it a quick read-through. Do you spot any mistakes? If the problem is blatantly obvious to you, you can stop reading 🙂.

defmodule UserTest do
  use DataCase

  test "inserting user applies default settings" do
    # given
    attrs = %{}
    changeset = User.changeset(attrs)

    # when
    user = Repo.insert!(changeset)

    # then
    assert %{settings: %{notify?: true}} = user
  end

  test "listing users to notify returns user with default settings" do
    # given
    %{} |> User.changeset() |> Repo.insert!()

    # when
    users = Repo.all(from u in User, where: u.settings["notify?"] == true)

    # then
    assert length(users) == 1
  end
end
defmodule User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    embeds_one :settings, Settings do
      field :notify?, :boolean, default: true
    end
  end

  def changeset(user \\ %User{}, attrs) do
    user
    |> cast(attrs, [])
    |> cast_embed(:settings)
  end
end
defmodule Repo.Migrations.CreateUsers do
  use Ecto.Migration

  def change do
    create table(:users) do
      add :settings, :map, default: %{}
    end
  end
end

The Turn: It's broken

For me, this was a surprising turn.

The first test is fine. Looks like the newly inserted user is saved with default settings:

user = %{} |> User.changeset() |> Repo.insert!(changeset)
assert %{settings: %{notify?: true}} = user

But is it really? 😱 Then why does the next test fail?

users = Repo.all(from u in User, where: u.settings["notify?"] == true)
assert length(users) == 1

While the default value is returned by Repo.insert!/1 , it's not present in the database. Rather, the users database table currently looks like this:

id, settings
 1, {}

Ecto did not receive a value for the settings embed, and instructs the database to save settings = NULL. The database default, as specified in the migration, then kicks in: settings = is stored instead of NULL.

Note: Ecto doesn't support embed defaults.

What makes this confusing is that Ecto "hides" this. When reading a user from the database, Ecto "lazily" applies the default values when reading (when casting the empty JSONB object to the Settings schema).

user_with_lazy_defaults = Repo.get!(User, 1)

But a database WHERE query on settings.notify? fails to find the user, since the default notify? value was not written to the database.

empty_list = Repo.all(from u in User, where: u.settings["notify?"] == true)

The Prestige: How to fix it

This is actually not surprising and luckily quite simple. Since this is not a magic trick, I hope you can forgive me for failing to give you a truly captivating Prestige 😉.

%{settings: %{}}
|> User.changeset()
|> Repo.insert!()

Passing an empty map for the embed changeset is enough. Ecto then casts this to the Settings struct and applies the default field values.

We end up with this database users table state:

id, settings
 1, {"notify?": true}

Ecto works as expected

To be clear: This is not an Ecto bug. Ecto is doing exactly what it should. The bug is in my head, because I misinterpret how defaults and embeds work. And because my first test reinforces my understanding.

What made it especially confusing in the project at hand was the fact that it was a regression bug. The notify? flag was previously stored top-level as User.notify?. Everything worked as expected, because Ecto field defaults for top-level fields are saved to the database.

Then, a bunch of user flags was grouped and moved into the Settings embed. Along came the problem: New users suddenly didn't receive notifications.

But wait: Not all users. That would have been easy to spot 😉. Instead, there was a default manual process in place: After creating a user, usually the user settings were manually edited via the UI. As a result, the lazy user.settings.notify? default value was saved to the database

This manual process further masked the issue, because most new users did end up with {"notify?": true} in the database and were thus notified.

Over to you

Is there a better fix? Should we validate_required(:settings)?

Or is there a better mental model to avoid falling into this trap in the first place?

The post When to use field :default in an Ecto schema by Dan Schultzer also touches on the subject.