Ecto associations - is put_assoc the correct choice?

I’ve never felt too clear on the right way to build associations.

Creating a new post for a pre-existing Author. I would like the author struct to be available on the changeset for later logic/validation.

Question:
What is the correct way to implement associate_with(changeset, author)?

def create_post(author, params) do
  %Post{}
  |> Post.changeset(params)
  |> associate_with(author)
  |> ensure_author_has_rights()
  |> other_validation_that_may_need_author()
  |> Repo.insert()
end

# in this contrived example, I am aware that I could 
# simply pass in the author as an argument :)
def ensure_author_has_rights(changeset) do
  author = changeset.changes.author

  case author.type do
    "normal_user" -> changeset
    "admin_user" -> add_error(changeset, :author_id, "invalid author")
  end
end

The problem with put_assoc:

def create_post(author, params) do
  %Post{}
  |> Post.changeset(params)
  |> put_assoc(author)

This does achieve what I want:

def ensure_author_has_rights(changeset) do
  author = apply_changes(changeset.changes.author)

So clearly the changeset seems to think I am updating the Author, which makes me think this isn’t the correct approach:

Ecto.changeset.changes{
author: #Ecto.Changeset<action: :update, changes: %{}, errors: [], data: #App.Accounts.Author<>, valid?: true>
}

Not sure if this is the correct way to do it as there are multiple ways, but I typically include the author_id in the changeset.

def changeset(post, attrs) do
  post
  |> cast(attrs, [:author_id])
  |> validate_required([:author_id])

In the context I add the author_id to the params.

def create_post(author, params) do
  params = Map.merge(params, %{"author_id" => author.id})

  %Post{}
  |> Post.changeset(params)
  ...

That will allow Post.changeset to perform all the basic validations within the function and ensure we have an author if one is required.

For any additional validations that don’t fit into Post.changeset, I usually just pass the struct as an argument since it is already available at the callsite.

  %Post{}
  |> Post.changeset(params)
  |> ensure_author_has_rights(author)

Maybe others have more idiomatic methods.

1 Like

Thanks @baldwindavid

The problem with that method, for my current use-case, is that I have a lot of associations with item I am inserting, and there is a lot of logic to be run, so it becomes cumbersome to pass in all of the associations as arguments.
(code snippet updated to reflect that I am aware of this as an option)

put_assoc does do the job, but the fact that it adds the association as an update scares me slightly.

If there are no good alternatives, perhaps someone can tell me if put_assoc should be avoided in my use-case for any reason?

When it comes to associating data without updates while not letting them through my changesets because they’re not supposed to be changed by the user directly, i always use put_change. In your case, i would do

def create_post(author, params) do
  %Post{}
  |> Post.changeset(params)
  |> Changeset.put_change(:author_id, author.id)
  |> ensure_author_has_rights(author)
  |> other_validation_that_may_need_author(author)
  |> Repo.insert()
end

(or something else in place of author_id if you’ve changed the association name).

Obviously, that means the author associations won’t be loaded into the %Post{}. If you really have a lot of them to pass through multiple functions, you could maybe create a Keyword list to only pass that one and have access to all the data you need ([author: %Author{}, other: %Other{}]) in subsequent functions. Otherwise, just pass through the author (like i did on the code snippet).

Thanks for the advice.

put_assoc allows me to do this:

%Author{} = change(%Post{}) |> put_assoc(:author, author) |> apply_changes() |> Map.fetch!(:author)

Which is really useful because I have built logic to work with the struct.

Overall I am thinking that put_assoc is really a good fit for me here. I really appreciate the input.

If anyone has any thoughts on why put_assoc` is a bad choice, then I’d love to know. Otherwise I’ll stick with it for now.

Well documentation for put_assoc says:

This function is used to work with associations as a whole. For example, if a Post has many Comments, it allows you to add, remove or change all comments at once. If your goal is to simply add a new comment to a post, then it is preferred to do so manually, as we will describe later in the “Example: Adding a comment to a post” section.

https://hexdocs.pm/ecto/Ecto.Changeset.html#put_assoc/4

Seems to me you are trying to add a Post and just associate the author + validations. If this is the case, I think you are better off with build_assoc which will add the author’s id in the %Post{} struct not the params map. You will have to pass the author to the changeset functions as your code shows.

I am not in my PC to verify but build_assoc will not add a change to the changeset for the association because it returns a child struct.

If its not the case, my bad, y misunderstood.

Best regards,

Thanks @joaquinalcerro.

I specifically want the association available on the changeset so build_assoc is not useful. The reason I want the association available is that I have functions that work on a preloaded struct, so if I am in the changeset flow, I can simply run apply_changes and then am able to perform the logic/calculations necessary.

Again thanks for the input!