How to populate join table fields with Ecto.put_assoc/4

Felipe Renan
3 min readMar 9, 2021

Recently, I had to deal with a many-to-many relationship on my current Elixir project. During this brief journey I’ve learned the usage of join_defaults option present on many-to-many macro and, for the first time ever, I would like to share writing a blog post!

It is worth mentioning that I’m using Ecto.Changeset.put_assoc/4 to associate records similarly to how it is being used in this Ecto guide.

The issue

The issue is in the title of this post. Besides building the many-to-many association between two records, I had to populate some extra fields in the table that is used to join these associations.

In order to simplify my example, I’m not going to explain how many-to-many works or how you can get it working with Ecto. Instead, I’d suggest you to read the the Ecto guide mentioned above which I believe will cover everything you need to know.

Once you are familiar with how many-to-many works in Ecto, we can go straight to my example.

Ecto schema

I will use a pretty similar schema used in that Ecto guide with just one modification: I’m adding the field inserted_at in the post_tags table which is the table that joins posts with tags.

create table(:posts) do
add :title
add :body
timestamps()
end
create table(:tags) do
add :name
timestamps()
end
create unique_index(:tags, [:name])create table(:posts_tags, primary_key: false) do
add :post_id, references(:posts)
add :tag_id, references(:tags)
timestamps(updated_at: false)
end

Now, how can we populate this inserted_at present in the post_tags table when associating posts with tags?

Populating the join table fields

The first step is to change the schema to:

defmodule MyApp.Post do
use Ecto.Schema
schema “posts” do
# omitted...
many_to_many :tags, MyApp.Tag, join_through: PostTag
end
end

The only difference is that join_through now takes the PostTag schema instead of the table’s name which was just a string. That is all. Ecto now will populate the inserted_at in the posts_tags using the current timestamps at the moment this association is built.

Unfortunately, in my case it wasn’t just the inserted_at that needed to be populated but a couple of fields more. Fortunately, Ecto provides three different ways for achieving that.

Using default values in the schema

defmodule MyApp.PostTag do
use Ecto.Schema
schema “posts_tags” do
# … omitted
field :some_field, default: "some-value"
end
end

Using join_defaults passing keyword list

defmodule MyApp.PostTag do
use Ecto.Schema
schema “posts” do
# omitted ...
many_to_many :tags, MyApp.Tag,
join_through: PostTag
join_defaults: [some_field: "some_value"]
end
end

Using join_defaults passing MFA (Module Function Arity)

defmodule MyApp.Post do
use Ecto.Schema
schema “posts” do
# omitted ...
many_to_many :tags, MyApp.Tag,
join_through: PostTag,
join_defaults: {__MODULE__, :build_post_tag, []}
end

def build_post_tag(post_tag, _post) do
%{post_tag | some_field: "some_value"}
end
end

The first two approaches are pretty similar. The difference is that in the first one you put the default values in the schema while the second one in the association itself using the option join_defaults.

The third one, you have more control over the whole operation since you are telling Ecto to use some custom function which gives you the opportunity to evaluate any value you want for posts_tags.

In my case, I end up using the third option since I had to take some values from the second argument from the function given to the MFA.

--

--

Felipe Renan

A Software developer who loves hacking beautiful code :)