Ecto: put_change not working on nested changeset when updating

Hello !

I’m a newcomer in the Elixir / Ecto / Phoenix world, and having an issue with how to handle associations in Ecto.

Considering the following (simplified) related schemas and changesets:

schema "base" 
    field :reference, :string
    field :designation, :string

    belongs_to :user, User
    has_many :base_components, BaseComponent, on_replace: :delete
  end

def changeset(base, attrs) do
    base
    |> cast(attrs, [
      :reference,
      :designation
    ])
    |> Changeset.cast_assoc(
      :base_components,
      required: true
    )
    |> validate_length(
      :components,
      min: 2,
    )
  end

schema "base_components"
    field :quantity, :decimal

    belongs_to :user, User
    belongs_to :base, Base
end

def changeset(base_component, attrs) do
    base_component
    |> cast(attrs, [
      :quantity
    ])
  end

I’m basically trying to insert / update a Base along with it’s related BaseComponent's (enforcing a minimum of 2 to be provided). Using the following context function body (both user and attrs are provided in the function params) for insert:

%Base{}
|> Base.changeset(attrs)
|> Changeset.put_change(:user_id, user.id)
|> Changeset.update_change(:base_components, fn changesets ->
  changesets |> Enum.map(&Changeset.put_change(&1, :user_id, user.id))
end)

and this one for update:

base
|> Repo.preload(:base_components)
|> Base.changeset(attrs)
|> Changeset.update_change(:base_components, fn changesets ->
  changesets |> Enum.map(&Changeset.put_change(&1, :user_id, user.id))
end)

Now, on insert, everything goes according to plan, but when updating, i’m having an issue (while unit testing). When the cast_assoc highlights that some BaseComponent will be deleted, others updated and some inserted (usually between 2 to 5 base components are provided), i get the following error:

** (RuntimeError) cannot replace related %MyApp.MyContext.BaseComponent{} because it already exists 
and it is not currently associated with the given struct. Ecto forbids casting existing records through
the association field for security reasons. Instead, set the foreign key value accordingly

From what i understand, it comes from the presence of a changeset marked to be deleted, which action isn’t permitted to be changed. Note that if i add the put_change directly on the changeset for BaseComponent (right after cast) providing the user params to the changeset function, it works (probably because it’s not applied on the deleted associations).

Is there a way to do what i want (using put_change), without having to do it in the changeset function (keeping those changes in the context function and avoiding having internal data in changeset functions which i want to stay strictly related to user provided data) ?

Thanks

4 Likes

Well, i actually figured it out !

The trick is to filter the changesets who have the :replace action (the ones who are getting deleted), and only feeding the others to update_change. Ecto is so smart that it’s just replacing the ones i’m giving it, leaving the others untouched.

My solution for the update function looks like this

base
|> Repo.preload(:base_components)
|> Base.changeset(attrs)
|> Changeset.update_change(:base_components, fn changesets ->
  changesets 
  |> Enum.filter(&(&1.action != :replace))
  |> Enum.map(&Changeset.put_change(&1, :user_id, user.id))
end)

If anyone thinks it’s a bad idea, let me know :wink:

5 Likes

Wow. I’ve been trying to solve this very issue in my project since last year, and your one-liner just fixed it for me :heart_eyes: Thank you @shad!

2 Likes