Implicitly creating and replacing a singleton has_one/belong_to entity via Repo.insert in Ecto

I’m not sure if this can or should be handled automatically by Repo.insert (probably not), but I’m trying to figure out a way to implicitly replace a singleton entity associated with another instead of receiving a uniqueness violation error.

I have a simple User model with an associated Avatar via a has_one and belong_to relationship.

I am using phoenix_ecto ~> 4.0, ecto_sql ~> 3.0 and posgrex ~> 0.0.0. I’m also using arc_ecto to make my file uploads to S3 easier, but that’s not too relevant (I just included it in the following snippets to be thorough).

Here is the User model:

defmodule Example.User do
  use Example.Web, :model

  schema "users" do
    field :email, :string
    field :username, :string
    # ...
    has_one :avatar, Example.Avatar
  end

  # ...
end

The Avatar model:

defmodule Example.Avatar do
  use Example.Web, :model
  use Arc.Ecto.Schema

  alias Example.{Avatar, User, Media}

  schema "avatars" do
    field :file, Media.Avatar.Type # `arc_ecto` construct
    belongs_to :user, User, on_replace: :update

    timestamps()
  end

  def changeset(%Avatar{} = struct, params \\ %{}) do
    struct
    |> cast(params, [:file])
    |> cast_attachments(params, [:file])
    |> validate_required([:file])
    |> put_assoc(:user, params.user)
    |> unique_constraint(:user, name: :avatars_users_id_index)
  end
end

The migration for creating the Avatar entity:

defmodule Example.Repo.Migrations.CreateAvatar do
  use Ecto.Migration

  def change do
    create table(:avatars) do
      add :file, :string # @see https://github.com/stavro/arc_ecto#add-a-string-column-to-your-schema
      add :user_id, references(:users, on_replace: :update)

      timestamps()
    end

    create unique_index(:avatars, [:user_id])
  end
end

The controller for Avatar:

defmodule Example.AvatarController do
  use Example.Web, :controller

  alias Example.{Avatar, Auth}

  def create(conn, %{file: file}) do
    user = Auth.Guardian.Plug.current_resource(conn) # fetches session user
    changeset = Avatar.changeset(%Avatar{}, %{file: file, user: user})

    case Repo.insert(changeset) do
      {:ok, avatar} ->
        conn
        |> put_status(:created)
        |> put_resp_header("location", avatar_path(conn, :show, avatar))
        |> render("show.json", avatar: avatar)
      {:error, changeset} ->
        conn
        |> put_status(:unprocessable_entity)
        |> put_view(Example.ChangesetView)
        |> render("error.json", changeset: changeset)
    end
  end
end

Unsurprisingly, first Avatar that I create for a User via a POST request works just fine.

The “problem” is that a subsequent POST ends up resulting in a unique constraint violation:

{"errors":{"user":["has already been taken"]}}

I was expecting the single Avatar entity to be implicitly updated/replaced, but I’m thinking that’s beyond the scope and semantics of Repo.insert (and, arguably, POST).

Should I instead just conditionally perform a PATCH/PUT (and thus a conditional Repo.update) from my client depending on whether the Avatar entity already exists for the User? This works just fine, but I’d love to avoid conditional request logic in the client if possible.

I think the answer is pretty obvious as I’ve reached the end of this question - just perform conditional POST and PUT/PATCH requests and call Repo.insert and Repo.update accordingly. In fact, I think it must be done this way. Otherwise Ecto would never be able to report a constraint violation for 1:1 relationships upon insertion, and that is truly problematic.

Anyways, I thought it was worth posting just in case someone else thinks about things in a similar way and has the same question.

Thanks!

You are probably looking for :on_conflict option of Ecto.Repo.insert/2.

2 Likes

The behavior you’re describing sounds like what you’d get with a different setup:

  • User declares has_one :avatar, Example.Avatar, on_replace: :delete
  • the controller creates a changeset for User that includes put_assoc(:avatar, ...)
  • that changeset is passed to Repo.update
1 Like

Ah yes, I’m not sure how I missed that! I think I was too focused on the schemas and changesets :drooling_face:.

Using on_conflict: replace_all in addition to specifying the conflict_target did the trick for me:

defmodule Example.AvatarController do
     user = Auth.Guardian.Plug.current_resource(conn)
     changeset = Avatar.changeset(%Avatar{}, %{file: file, user: user})
 
-    case Repo.insert(changeset) do
+    case Repo.insert(changeset, on_conflict: :replace_all, conflict_target: :user_id) do

Much appreciated!

1 Like