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!