How to populate join table fields with Ecto.put_assoc/4
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()
endcreate table(:tags) do
add :name
timestamps()
endcreate 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.