Ecto Associations on Replace

| May 6, 2024 min read

Introduction

This posts focuses on Ecto’s on_replace option that is relevant when working with associations. We’ll break down when it becomes relevant and explore the options that show up. We can think of this option as defining a strategy to take when associations are severed.

  • Temporary Ownership

Reminder: Ecto Associations are All or Nothing

Remember that association methods like cast_assoc work with the associations as a whole. This means we must view the data being placed in those methods as the desired value for the whole association. This is a common misconception in the beginning when working with Ecto. For example the following case as the documentation is the wrong way to insert a new comment to a blog post.

post
|> Ecto.Changeset.change()
|> Ecto.Changeset.put_assoc(:comments, [%Comment{body: "bad example!"}])
|> Repo.update!()

The reason why the example above is wrong is because put_assoc/4 always works with the full data. So the example above will effectively erase all previous comments and only keep the comment you are currently adding. Instead, you could try - Ecto Docs

Warning

Note that the documentation gives us extra warnings when using the :delete or :delete_if_exists options. It’s bad enough if you use put_assoc or the like to wrongly. Like we mentioned you tried to insert an additional element into the list and instead, you replaced the whole list with a single element. Delete and Delete if Exists will actually delete the data behind the associations. Simply put:

  • put_assoc like functions mistakes result in the loss of data for a single Parent Schema
  • delete or :delete_if_exists like functions mistakes result in the loss of data for ALL Parents relationships dependent on the same child elements

When is something replaced

In the docs it states: “When using any of those APIs, you may run into situations where Ecto sees data is being replaced. For example, imagine a Post has many Comments where the comments have IDs 1, 2 and 3. If you call cast_assoc/3 passing only the IDs 1 and 2, Ecto will consider 3 is being “replaced” and it will raise by default.”

Essentially we’ll look at the initial list and see 1,2,3. We look at the final state and see [1,2]. What should we do with the element with ID 3? In the world of Ecto we’ll consider this element replaced!

Why Is this Needed?

Before we explore the replace strategies, I just want to highlight why this is even necessary. Ultimately, it’s tied to understanding that the changes we want to do with this associations is not what we necessarily want done to our database.

Let’s take the docs example of a post initially having comments with [1,2,3] and later with [1,2]. As the application developer you might want to just consider this as a delete. Maybe the user deletes their comment, the frontend removes this item from the list and then makes a request to the backend that the new list of comments should just be [1,2].

However, from the standpoint of the application as a whole we might actually want this data to still exist. If we just delete the element from the database then we can’t use this information. Maybe a moderator deleted this comment. It’s reasonable that if a user has their comment deleted enough from moderators that we block that user or if we have the bandwidth to set some internal flag that this user needs to be monitored. Therefore, this operation is not a delete from the database, its moreso updating the associations so that the post no longer shows this comment.

What does that mean?

Hopefully why it’s needed is a little more clear. Now we’ll go through the allowed values.

Raise (default)

:raise (default) - do not allow removing association or embedded data via parent changesets

This just means that by default you need to explicitly state your policy. If you try to make changes without defining this on_replace option we won’t let you. Note that this also says via parent changesets. This just means that you can delete the element from the child point of view.

Repo.delete(comment)

Mark as Invalid

:mark_as_invalid - if attempting to remove the association or embedded data via parent changeset - an error will be added to the parent changeset, and it will be marked as invalid

This value is only slightly less strict than the :raise option. Instead of throwing an error, we just consider the changeset invalid.

When might you want this? You’re explicitly not allowing removal of this association. So once something is created it cannot be removed. I haven’t used this but according to ChatGPT a useful case could be in an Book and Authors relationship. Once a book is created the authors would never change so you can explicitly deny any changeset that attempts to delete the association.

Nilify (Delete in the eyes of the association ONLY)

:nilify - sets owner reference column to nil (available only for associations). Use this on a belongs_to column to allow the association to be cleared out so that it can be set to a new value. Will set action on associated changesets to :replace

This is a common scenario. Think of something with temporary ownership for example. Maybe you’re making a Car Rental service app. One specific car could have many people use it throughout the years. We wouldn’t want to just delete the information of people since Car History is crucial. However, there are moments where no one is currently renting a car.

 def return_car(car_id) do
    car = Repo.get!(Car, car_id)

    changeset = Car.changeset(car, %{current_owner_id: nil})

    case Repo.update(changeset) do
      {:ok, _car} ->
        {:ok, "Car has been successfully returned"}
      
      {:error, changeset} ->
        {:error, "Could not return the car", changeset}
    end
  end

In this case we want the changeset to be considered valid which eliminates our previous values :raise and :mark_as_invalid.

Update

:update - updates the association, available only for has_one, belongs_to and embeds_one. This option will update all the fields given to the changeset including the id for the association

With closely tied data, update might make more sense. An (ChatGPT) example of this might be Orders and LineItems.

  schema "orders" do
    ...
    has_many :line_items, LineItem, on_replace: :update
  end

  schema "line_items" do
    field :product_name, :string
    field :quantity, :integer
    field :price, :decimal

    belongs_to :order, MyApp.Order
    ...
  end

In this case the data is tied closely enough where if a line item is changed, then we would maybe just want to update that specific line item, instead of deleting and creating a new item.

In order to make things more clear lets compare and contrast things with nilify. In both these cases we were able to result in a valid changeset. :raise and :mark_as_invalid both prevented this. The key difference is that :nilify does not “touch” the association whereas :update does.

Delete

This one is pretty straight forward. In some cases you really don’t care about the associated item or you just want to delete things so that our database does not get flooded. You could think of a person deleting their account. Let’s say the data is not too important or you want to value their data privacy. You actually want to delete the associations.

Delete If Exists (Race Condition)

In some cases where multiple independent people could delete the same element we’d run into a race condition. User One makes a request which deletes the element. User Two makes a request at a slightly later time trying to delete an element just marked as deleted. Ecto will complain with Ecto.StaleEntryError. This is a more specific flavor of delete that prevents this. This is only relevant again in a situation where something could be requested to be deleted multiple times.

Why shouldn’t we always use it.

  • This does not apply to embedded data