Changeset.put_change after Changeset.cast_assoc fails with RuntimeError

I’m trying to use Ecto’s new :sort_param attribute on a cast_assoc to apply the sort order I specified in my form. Since I’m using a belongs_to relationship, I’m doing this in two steps as specified in the docs:

    changeset
    |> cast_assoc(:element_photos,
      sort_param: :element_photos_order
    )
    |> reorder_photos()

...


  defp reorder_photos(changeset) do
    if element_photos = Ecto.Changeset.get_change(changeset, :element_photos) do
      element_photos
      |> Enum.with_index(fn element_photo, index ->
        Ecto.Changeset.put_change(element_photo, :order, index)
      end)
      |> then(&Ecto.Changeset.put_change(changeset, :element_photos, &1))
    else
      changeset
    end
  end

But this always fails for me:

** (RuntimeError) cannot replace related %Ido.Elements.ElementPhoto{...}. This 
typically  happens when you are calling put_assoc/put_embed with the results of a 
previous put_assoc/put_embed/cast_assoc/cast_embed operation, which is not 
supported. You must call such operations only once per embed/assoc, in order 
for Ecto to track changes efficiently
    (ecto 3.10.0) lib/ecto/changeset/relation.ex:430: Ecto.Changeset.Relation.check_action!/2
    (ecto 3.10.0) lib/ecto/changeset/relation.ex:180: Ecto.Changeset.Relation.do_change/4
    (ecto 3.10.0) lib/ecto/changeset/relation.ex:347: Ecto.Changeset.Relation.map_changes/9
    (ecto 3.10.0) lib/ecto/changeset/relation.ex:156: Ecto.Changeset.Relation.change/3
    (ecto 3.10.0) lib/ecto/changeset.ex:1729: Ecto.Changeset.put_change/7
    (ecto 3.10.0) lib/ecto/changeset.ex:1720: Ecto.Changeset.put_change/3

How do I workaround this? I tried replacing that last put_change with an update_change, but I get the same error. I’m not sure what to do here, as the docs specifically suggest using cast_assoc and then put_change like this.

Thanks!

Hmm, that is quite odd…

Just an idea, but maybe try swapping get_change for get_field to avoid calling a put_change on the children after the cast_assoc.

  defp reorder_photos(changeset) do
    if element_photos = Ecto.Changeset.get_field(changeset, :element_photos) do
      element_photos
      |> Enum.with_index(fn element_photo, index -> %{element_photo | order: index} end)
      |> then(&Ecto.Changeset.put_change(changeset, :element_photos, &1))
    else
      changeset
    end
  end

Also, I wonder if flipping the pipeline would make a difference. :thinking:

    changeset
    |> reorder_photos()
    |> cast_assoc(:element_photos, sort_param: :element_photos_order)

Hi codeanpeace, thanks for the ideas! Sadly those won’t work either.

If I use get_field instead of get_change, then I’ll always be putting the update in the changeset regardless of whether there was a change. Then I’ll still hit the issue of Ecto not wanting it to be changed twice.

I can’t flip the order in the pipeline, as the cast_assoc is needed to pull the data out of the attrs and update the order based on the sort param.

You can use the changeset from before you cast_assoc, while still using the one from after cast_assoc to build the put_change payload (which maybe should be put_assoc?)

Oof amazingly that doesn’t work either. It also inspired me to try changeset |> delete_change(:element_photos) |> put_change(:element_photos, element_photos) which also fails. I’m guessing the failure is actually because of the nested Changesets- it sees you’re trying to set a change that contains Changesets, and decides you already did that and it’s not allowed.

All these attempts also feeling like I’m fighting against Ecto- I’m not sure what the elegant proper way to do this is.

I’d open an issue on the ecto repo. If things fail in the way they’re documented that’s a bug.

Yes, please open up an issue. The docs are wrong and we need to find alternative ways to tackle this problem.

1 Like

Ok thanks! Glad to know I wasn’t missing something. Ticket opened:

1 Like

I felt similarly when brainstorming possible solutions.

Yeah, the error message shows the nested %ElementPhoto{} struct which is why I was curious if swapping the get_change and nested put_change with a get_field and nested struct update would make a difference.

A potential temporary solution could be using the list :element_photos_order passed into :sort_param option as a dictionary to directly add/update the :order keys when Enum.maping through the association in the changeset before casting the association. It does still feel a bit roundabout though.

Yeah, I think the only way to do this would be outside of the changeset. Otherwise anything I do or anything the changeset does will collide. I was already handling sort manually like this, so I’m just reverting my attempt with :sort_param until there’s another approach in Ecto. Thanks for the help!

1 Like

A feature to better do this was merged: Support receiving index in relation cast by v0idpwn · Pull Request #4178 · elixir-ecto/ecto · GitHub
Now you should be able to pass a function of arity 3 to :with and it will receive the index as the third parameter.

2 Likes