Ecto: Update relation without preloading

I have a situation in which I have two models with a belongs_to between them. As
an example, I’ll use the typical Post -> Author models:

defmodule Post 
  schema "posts" do
    field :title, :string
    belongs_to :author, User
  end
end

defmodule User
  schema "users" do
    field :name, :string
  end
end

Now I have an instance of a Post, and want to change the author of that Post.
The author I want to update the Post to I have in memory, and the current author
relation is not preloaded.

def change_author(%post{} = post, %user{} = author)
  post
  |> Post.update_changeset(%{author_id: author.id})
  |> Repo.update()
end

with the following changeset function:

defmodule Post
  # ... schema ..
  def update_changeset(post, attrs) do
    post
    |> cast(attrs, [:author_id])
    |> foreign_key_constraint(:author_id)
  end
end

The problem with this approach is that the resulting Post model returned by
the Repo.update call doesn’t have the updated User instance.

Another option would be to use put_assoc instead of using the author_id key
directly, but when using put_assoc Ecto requires the related resource (author
in this case) to be preloaded, which requires an extra database call for a
resource that I don’t need.

For now I can solve this with something like:

def change_author(%post{} = post, %user{} = author)
 post
  |> Post.update_changeset(%{author_id: author.id})
  |> Repo.update()
  |> case do
    {:ok, %Post{} = post} ->
      {:ok, Map.put(post, :author, author)}
    {:error, changeset} ->
      {:error, changeset}
  end
end

… but that extra case just to manually add the relationship again doesn’t
seem very clean.

Is there a better way of solving this wihtout performing needless database calls?

:wave:

Instead of Map.put/3 you can use %{post | author: author}, the latter is a bit more strict.

As for your original question, I’d probably go with Repo.update_all to update the :author_id (mostly to avoid casting the foreign key), but still “insert” the user struct into the post struct manually.

@spec change_author(%Post{}, %User{}) :: %Post{}
def change_author(%Post{id: post_id} = post, %User{id: author_id} = author) do
  {1, nil} = 
    Post
    |> where(id: ^post_id)
    |> Repo.update_all(set: [author_id: author_id])

  %{post | author_id: author_id, author: author}
end

Thanks for the reply.

The update_all approach could work, however I would also like to update several other (non-FK) attributes in the same method. With the update_all approach it would need to update all those keys manually as well, which is normally done by the changeset.

I’ll look into it some more.

I believe you can have the updated author associated to the post before you call update, and it would also simplify handling the changeset:

def change_author(%post{} = post, %user{} = author)
  %{post | author: author}
  |> Post.change()
  |> Repo.update()
end

You can still add the foreign_key_constraint to the changeset if you’d like, but the key is to not rely on setting just the foreign key, but setting the :author relation to the existing author struct as I showed above.

This didn’t quite make it, because now the changeset doesn’t know that the author has actually changed. It did help me find a solution for this for now:

Rather than setting the relation to an existing model, set it to nil to fool Ecto into thinking it’s preloaded (because the Ecto.Association.NotLoaded is now gone). End result is something like this:

# context
def change_author(%Post{} = post, %User{} = author)
  %{post | author: nil}
  |> Post.change(%{author: author})
  |> Repo.update()
end

# schema
def change(post, attrs)
  post
  |> cast(attrs, ~w(attrs here)a)
  # ... validation rules
  |> put_assoc(:author, attrs.author)
end
1 Like

I think in my case I was trying to create a new object (and associating another existing object to it) instead of updating an existing one. Glad you found a way to make it work!