Changeset composite primary key for deleting

Hello

I am trying to write a subject-follower relationship schema. I have this migration

defmodule TweetClone.Repo.Migrations.CreateUserRelationships do
  use Ecto.Migration

  def change do
    create table(:user_relationships, primary_key: false) do
      add :subject_id,
          references(:users,
            on_delete: :delete_all,
            on_update: :update_all,
            primary_key: true
          )

      add :follower_id,
          references(:users,
            on_delete: :delete_all,
            on_update: :update_all,
            primary_key: true
          )

      timestamps()
    end

    create unique_index(:user_relationships, [:subject_id, :follower_id])
  end
end

and this schema

defmodule TweetClone.UserRelationships.UserRelationship do
  use Ecto.Schema
  import Ecto.Changeset

  alias TweetClone.Accounts.User

  @primary_key false
  schema "user_relationships" do
    belongs_to :follower, User, primary_key: true
    belongs_to :subject, User, primary_key: true

    timestamps()
  end

  @doc false
  def changeset(user_relationship, %{subject: subject, follower: follower}) do
    user_relationship
    |> change
    |> put_assoc(:subject, subject)
    |> put_assoc(:follower, follower)
    |> validate_required([:subject, :follower])
  end 
end

The intent is that UserRelationship not to have a auto generated id but a composite made from the foreign keys subject_id and follower_id

It works fine and when I want to delete that row I do

  def delete_user_relationship(subject_id, follower_id) do
    query =
      from u in UserRelationship,
        where: u.subject_id == ^subject_id,
        where: u.follower_id == ^follower_id

    Repo.delete_all(query)
  end

and the row is deleted.

When I try do delete based on the changeset I use this function

  def delete_user_relationship(subject, follower) do
    attrs = %{
      subject: subject,
      follower: follower
    }

    %UserRelationship{}
    |> UserRelationship.changeset(attrs)
    |> Repo.delete()
  end
iex(29)> UserRelationships.delete_user_relationship( user3, user1)
** (Ecto.NoPrimaryKeyValueError) struct `%TweetClone.UserRelationships.UserRelationship{__meta__: #Ecto.Schema.Metadata<:built, "user_relationships">, follower: #Ecto.Association.NotLoaded<association :follower is not loaded>, follower_id: nil, inserted_at: nil, subject: #Ecto.Association.NotLoaded<association :subject is not loaded>, subject_id: nil, updated_at: nil}` is missing primary key value
    (ecto 3.4.2) lib/ecto/repo/schema.ex:903: anonymous fn/3 in Ecto.Repo.Schema.add_pk_filter!/2
    (elixir 1.10.1) lib/enum.ex:2111: Enum."-reduce/3-lists^foldl/2-0-"/3
    (ecto 3.4.2) lib/ecto/repo/schema.ex:427: anonymous fn/10 in Ecto.Repo.Schema.do_delete/4

So it complains that our changeset is missing a primary key.

I tried to change the UserRelationship schema to pass only the user’s primary keys instead of the full users structs

  @primary_key false
  schema "user_relationships" do
    field :subject_id, :integer, primary_key: true
    field :follower_id, :integer, primary_key: true
    belongs_to :follower, User
    belongs_to :subject, User

    timestamps()
  end

but during compilation it complains that


== Compilation error in file lib/tweetclone/user_relationships/user_relationship.ex ==
** (ArgumentError) field/association :follower_id is already set on schema
    (ecto 3.4.2) lib/ecto/schema.ex:2001: Ecto.Schema.put_struct_field/3
    (ecto 3.4.2) lib/ecto/schema.ex:1758: Ecto.Schema.define_field/4
    (ecto 3.4.2) lib/ecto/schema.ex:1841: Ecto.Schema.__belongs_to__/4
    lib/tweetclone/user_relationships/user_relationship.ex:11: (module)
    (stdlib 3.11.2) erl_eval.erl:680: :erl_eval.do_apply/6
    (elixir 1.10.1) lib/kernel/parallel_compiler.ex:233: anonymous fn/4 in Kernel.ParallelCompiler.spawn_workers/7

How can I fix the UserRelationship schema so I can simply pass the users primary keys and be assigned as the composite primary key in the changeset? Or even passing the full User structs and be able to delete with the changeset?

I found a satisfactory solution to my problem. Turns out I didn’t need to delete by using a changeset. I just wasn’t satisfied with the return from the query based delete {1, nil}

I realized that one can do

 %{%UserRelationship{} | subject_id: 1, follower_id: 3} 
|> Repo.delete(stale_error_field: :subject, stale_error_message: "relationship does not exist")

or even better, with type safety

UserRelationship 
|> Repo.load(%{subject_id: 1, follower_id: 3}) 
|> Repo.delete(stale_error_field: :subject, stale_error_message: "relationship does not exist")

This returns {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()}

3 Likes