Ecto build_assoc and put_assoc

One area where Ecto gives you a lot of choices is when deciding how to creating and updating records with associations to other schemas. Before we finish this Ecto Beginner series, let's look at the options.

Working directly with foreign keys

The most straight-forward approach to handle records with associations is to work with their foreign keys directly. To do this, just include them in the cast filter in your changesets and pass in whatever data you need. Here's an example from the Bookmark module:

defmodule Linkly.Bookmark do
  use Ecto.Schema
  import Ecto.Changeset
  alias Linkly.{Bookmark, Link, User}

  schema "bookmarks" do
    field(:title)
    belongs_to(:link, Link)
    belongs_to(:user, User)

    timestamps()
  end

  def changeset(%Bookmark{} = bookmark, attrs) do
    bookmark
    |> cast(attrs, [:title, :link_id, :user_id])
    |> validate_required([:title, :link_id, :user_id])
    |> unique_constraint([:link_id, :user_id])
  end
end

We can pass in whatever values for link_id and user_id we want from our application. Then, it will be up to the application code to make sure they don't violate the uniqueness constraint, or at least to handle the error returned by the uniqueness constraint in the changeset. E.g.

%Bookmark{}
|> Bookmark.changeset(%{title: "Deviant Art", user_id: 823, link_id: 6491})
|> Repo.insert() 

And that's all we need.

  • Advantages: conceptual simplicity, no need to learn new syntax
  • Disadvantages: can be confusing to work with numerical IDs for foreign keys in join tables

Using build_assoc

Another option is to use Ecto.build_assoc/3. In this case, we can start with one record from the database and build an associated record. For example, if we've already retrieved a user, we can pass that user, along with the :bookmarks atom (which corresponds to the bookmarks association on the User schema), and the remaining attributes to create a new bookmark. We do not need to give it a user_id, since that will be inferred from the user.

sam = Repo.get_by(User, [username: "sam"])

new_bm = Ecto.build_assoc(
  sam,
  :bookmarks,
  title: "Lots of tech books"
)

This returns a Bookmark struct, not a changeset!

%Linkly.Bookmark{
  __meta__: #Ecto.Schema.Metadata<:built, "bookmarks">,
  id: nil,
  inserted_at: nil,
  link: #Ecto.Association.NotLoaded<association :link is not loaded>,
  link_id: nil,
  title: "Lots of tech books",
  updated_at: nil,
  user: #Ecto.Association.NotLoaded<association :user is not loaded>,
  user_id: 4
}

It's easy to create a changeset from it and insert it into the DB. We just need to pass it a link_id since that field is missing.

Bookmark.changeset(new_bm, %{link_id: 789})
|> Repo.insert()

This is a handy capability to create a new bookmark for an existing user.

Using put_assoc

Another handy function is Ecto.Changeset.put_assoc/3. It takes a changeset, an association name and attributes.

While build_assoc creates a new association on a record, put_assoc creates or replaces an association on a changeset. It's perfect in our case, since it's natural to create a new link when creating a new bookmark. Remember, links are just URLs and bookmarks are the join table between users and links they choose to bookmark.

Now, we can use put_assoc to replace the nil value for link_id in new bookmarks created with build_assoc and do everything in one pipeline.

Repo.get_by(User, [username: "sam"])
|> Ecto.build_assoc(:bookmarks, title: "Lots of tech books")
|> change()
|> put_assoc(:link, [url: "https://manning.com"])
|> Repo.insert()

Using put_change and cast_assoc

Two other very useful functions are put_change and cast_assoc.

Just as we use change for creating a changeset from trusted data coming from trusted data, we can use put_change to manually add a change to a changeset. E.g., Ecto.Changeset.put_change(cs, :agreed_to_terms, true).

As the name suggests, cast_assoc acts like the cast version of put_assoc, creating an entire associated struct from the changes in the changeset. For example, when dealing with form data that included a bookmark and a link, such as:

%{
  "title" => "Lots of tech books",
  "link" => %{
    %{"url" => "https://manning.com"}
  },
  %{"user_id" => "4"}
}

We could create a bookmark from the attributes above via the following:

%Bookmark{}
|> Bookmark.changeset(attrs)
|> cast_assoc(:link)
|> Repo.insert()

By default, cast_assoc will use the changeset function Link.changeset to cast the association since :link was the name of the association. You can specify a different changeset function to use by passing in an optional with: &your_changeset_function/2 parameter to cast_assoc.

Summary

  • There's more than one way to handle associations
  • build_assoc is very useful for creating a struct associated to an existing record
  • put_assoc creates or replaces the changes for an associated struct on a changeset
  • put_change manually adds a change to a changeset
  • cast_assoc creates or replaces the changes for an associated struct on a changeset, using a changeset function (which should call cast)
  • Be careful with put_assoc and cast_assoc if you're dealing with a collection. They will replace the entire collection! Usually, you should be using them for singular associations, not associations of many things.

(Source code available for premium members)

Back to index