Using put_assoc in model's changeset/2

I have a question about how to handle Ecto changesets that include associations. I’ve worked through several options but as far as I can tell they all work counter to the way changeset/2 works for standard fields or seem dangerous.

tl;dr

Is there a standard way to put_assoc when a association change is present in the changeset/2 params that also works if the associated item isn’t included (and so doesn’t need to be modified)?

There are several options I’ve run across and they all seem to have a downside or inconsistency:

Just pass the associated item’s id like a regular field.

This works but feels messy and opens the possibility of bypassing something important that Ecto would do when a new association is made.

params = %{mypost | user_id = "a nice uuid"}

Create the original struct with build_assoc

def create_settings(%MyApp.Users.User{} = user, attrs \\ %{}) do
  Ecto.build_assoc(user, :settings)
  |> Settings.changeset(attrs)
  |> Repo.insert()
end

Works nicely for creating the item. However, the associated id field isn’t included in the list of changes in the changeset. This isn’t a dealbreaker but it is confusing and could open the door for issues.

The bigger challenge is that there is no way to change the associated item using changeset/2. To make a change you would have to use something else that would work but now we have a changeset/2 function that doesn’t actually handle all the changes for an item.

cast_assoc in changeset/2

This works but will try to modify the associated items in a way that isn’t desired in many use cases - when a user owns an item for example.

put_assoc in changeset/2

def changeset(settings, attrs) do
  settings
  |> cast(attrs, [:timezone])
  |> put_assoc(:user, attrs.user)
  |> validate_required([:timezone])
end

Again, this works great but we’ve now accidentally required :user in our changesets. If you don’t put a user in params it will throw a messy error. This breaks the standard way changeset/2 is used. Usually, requirements are enforced by a requirement function of some sort and fields are optional unless required.

Is there a good way to handle this?

I don’t see why associations shouldn’t be handled in a way that is consistent with other changeset data while still using unique assoc functions to make sure any associated operations happen.

Is there a standard way to put_assoc when a change is present in the params that also works if the associated item isn’t included (and so doesn’t need to be modified)?

I’m tempted to write a “patch_assoc” that includes the change if present but I want to be as idiomatic as possible and not go down any unnecessary rabbit holes.

Hello and welcome,

You can create a conditional put_assoc…

defp maybe_put_user(changeset, attrs) do
  if user = (attrs["user"] || attrs[:user]) do
    put_assoc(changeset, :user, user)
  else
    changeset
  end
end

# and then

def changeset(settings, attrs) do
  settings
  |> cast(attrs, [:timezone])
  |> maybe_put_user(attrs)
  |> validate_required([:timezone])
end

I changed a little bit, because I don’t like attrs to have mixed atom/string keys. But it’s supporting both type of key.

I prefer to use build_assoc for ownership, because I don’t need/want to update it, once You have created something, You are the owner for good.

You are not limited to one changeset, You can have a create_changeset, and an update_changeset, both working differently.

Otherwise, a conditional put_assoc is what I would choose.

If I wanted to use aggregate root, I would choose cast_assoc, and let the root manage the rest.

Thanks for the reply and the welcome :slight_smile:

Good tip on not mixing atom and string keys. It feels a bit strange to turn a nice user struct into a map to pass it into attrs but this seems like a case where there isn’t a perfect solution.

I’ll give the maybe_put_user solution a try but let it support any key the way cast does. I have a lot of optional associations elsewhere and having something that would support updating would be nice.

Like you mention, I also was thinking about a changeset that would take a user or not but there seems to be a lot of standard operations that expect a changeset/2 and not a changeset/n. I’m not sure how all the form-submission magic in Phoenix would work if I start messing with this.

Thanks again!

It can be generic as well…

defp maybe_put_assoc(changeset, assoc, attrs) when assoc in ~w(user)a do
  if resource = (attrs[to_string(assoc)] || attrs[assoc]) do
    put_assoc(changeset, assoc, resource)
  else
    changeset
  end
end

# and call it like this...

def changeset(settings, attrs) do
  settings
  |> cast(attrs, [:timezone])
  |> maybe_put_assoc(:user, attrs)
  |> validate_required([:timezone])
end

You also need to preload assoc to do this

1 Like

That’s helpful. Thanks.

It feels a little like this is a hole in the changeset/assoc functionality and I’d love to see what other people have done to generalize a solution for assigning and modifying associations for a given model (or pointing out why it’s important to handle them differently).