Is cast_assoc/2 able to update belongs_to relationship?

I have the following:

  schema "communications" do
    belongs_to :recipient, Recipient
    belongs_to :user, User

    timestamps()
  end

  def changeset(communication, attrs) do
    communication
    |> cast(attrs, [:user_id, :recipient_id])
    |> cast_assoc(:recipient)
  end

Creating a communication with a new Recipient works:

attrs = %{
  recipient: %{email: "NewRecipient@email.com", name: "New Recipient"},
  user_id: user.id
}
|> Communications.create_communication()

=> {:ok, %Communication{}}

However, creating a new Communication with an existing Recipient does not.

attrs = %{
  recipient: %{
    id: recipient.id, # <-- Includes the ID
    email: "existing@email.com",
    name: "Existing Recipient"},
  user_id: user.id
}

changeset(comm, attrs) =>
%{
    recipient: #Ecto.Changeset<
      action: :insert, # <-- ** action is :insert **
      changes: %{email: "existing@email.com", name: "Existing Recipient"},
      errors: [],
      data: #Recipient<>,
      valid?: true
    >,
    user_id: 12345
  }

Based on the documentation:

If the parameter contains an ID and there is an associated child with such ID, the parameter data will be passed to MyApp.Address.changeset/2 with the existing struct and become an update operation

It’s my understanding, then, that if I include the ID of the recipient, the recipient will get updated whenever I create a new communication. And yet, this isn’t happening.

I’m not entirely sure if I misread the documentation, but including an existing recipient’s ID should update the recipient based on my understanding. Instead, it appears Ecto is attempting to create a new recipient.

Any pointers on what I’m getting wrong? Much appreciated. Thank you :pray:

cast_assoc is not meant to work with “a single element of a many-style association” and in your case, because a Communication belongs to a Recipient, I’m guessing a Recipient has many Communication.

This function should be used when working with the entire association at once (and not a single element of a many-style association) and receiving data external to the application.
Ecto.Changeset — Ecto v3.11.1

Instead of cast_assoc on the “child” resource’s changeset, I’d take a look at no_assoc_constraint/3 and foreign_key_constraint/3.

Since you’re trying to create a new Communication for an existing Recipient, I’d suggest taking a look at Adding a comment to a post section of the Ecto docs if you haven’t already.

Your code looks right and I tried it out in a local project and I got action: :update. Are you absolutely sure that recipient.id isn’t just returning nil or something?

A couple of notes:

This wouldn’t affect anything (at least I don’t think it would), but you don’t need :recipient_id in cast if you’re cast_assoc’ing the recipient anyway.

Also, in most situation it’s dangerous to cast the record owner’s :user_id as this means someone can send what ever user_id they want through POST data. It’s much safer to send current_user and put_assoc it:

def changeset(user, communication, attrs) do
  communication
  |> change() # Using `change` here since there aren't any `communication` attrs to cast
  |> put_assoc(:user, user)
  |> cast_assoc(:recipient)
end

I think you’re on to something here. The issue is that the Recipient may or may not exist. It seems, then, that I’ll need to call separate functions depending on whether or not the recipient exists. I know that the communication will not exist upfront and that the recipient is the variable here.

For example:

  • If the Recipient exists, create a communication and attach the recipient using put_assoc
  • If the recipient does not exist, create a communication and attach the recipient using cast_assoc

Thanks!

Yep! Totally sure. I’d love to see some code if you still got it. Did you do a belongs_to relation? Cause I think that’s what’s tripping me up with my approach.

Ah ya! Great point. :+1:

Thank you!

If anyone is curious about what I ended up doing, here’s what worked for me. Of course, there’s much room to improve, but it’s good enough for my use case.

# This gets called whenever I want to set the association IDs manually
def changeset(communication, attrs) do
  communication
  |> cast(attrs, [:user_id, :recipient_id])
end

# This is called whenever the attrs includes attrs for a non-existing recipient
def changeset_with_new_recipient(communication, attrs) do
  communication
  |> cast(attrs, [:user_id])
  |> cast_assoc(:recipient)
end

# This is called whenever we have an existing recipient
def changeset_with_recipient(communication, attrs, recipient) do
  communication
  |> cast(attrs, [:user_id])
  |> put_assoc(:recipient, recipient)
end

The call site looks like this for each of the changesets above.

def create_communication(%{recipient: _} = attrs) do
  %Communication{}
  |> Communication.changeset_with_new_recipient(attrs)
  |> Repo.insert()
end

def create_communication(attrs) do
  %Communication{}
  |> Communication.changeset(attrs)
  |> Repo.insert()
end

def create_communication(attrs, %Recipient{} = recipient) do
  %Communication{}
  |> Communication.changeset_with_recipient(attrs, recipient)
  |> Repo.insert()
end

Not a perfect solution, but it does precisely what I’m looking for.

This sounds about right. I’d just note that build_assoc is also an option for when the Recipient exists. But from reading the docs, cast_assoc for attaching a newly created Recipient doesn’t seem to be the right way to go about it.

The various changeset and create_communication functions feel like unnecessary complexity when you can just ensure that the recipient exists with a find or create. ¯\_(ツ)_/¯

Have you considered something like:

# pseudocode

def changeset(communication, attrs) do
  communication
  |> cast(attrs, [:user_id, :recipient_id])
  |> foreign_key_constraint(:user_id)
  |> foreign_key_constraint(:recipient_id)
end

# find an existing recipient or create a new recipient
recipient = Repo.get_by(Recipient, email: some_email) || Repo.insert(%Recipient{some_attrs})

# then create a communication using `build_assoc`
communication = build_assoc(recipient, :communications, attrs)

# or using `put_assoc`
communication = %Communication{} |> Communication.changeset(attrs) |> Repo.insert

Also, Ecto.Multi might come in handy if there’s more steps that you want to package into one transaction.