I wanted to share how I resolved this issue, which was not as trivial as I expected it to be. Thankfully, the Elixir community on Discord was able to guide me through a lot of this, so sending them a big thanks for all the help! Thank you!
There were a few things that I did not mention in my original post that complicated the matter. They were:
- Relying on Phoenix's nested forms to handle
many-to-many
associations
- Using
cast_assoc
in the User.changeset
to handle nested data
NOT NULL CONSTRAINT
on the user_role
field on the membership
table – the JOIN
table
Generally speaking, the key was to step away from the conveniences of the Phoenix framework and take a more "step-by-step" approach. In summary, the approach was to:
- Breakdown the
INSERT
s for each table separately
- Use
Ecto.Multi
and Ecto.Repo.transaction()
to execute the INSERT
s as a single transaction
- Remove, or rather do not use,
cast_assoc
within any changeset
function
- Manually set the
user_role
on the membership
- Create an
embedded_schema
to represent the form, extract relevant values for each table and cast those values as appropriate using the relevant changeset
function
The birds-eye view of this solution can be seen by looking at the Multi Transaction below:
1 Ecto.Multi.new()
2 |> Ecto.Multi.insert(:user, User.registration_changeset(%User{}, attrs))
3 |> Ecto.Multi.insert(:org, Organisation.changeset(%Organisation{}, %{name: org_name}))
4 |> Ecto.Multi.insert(:membership, fn %{user: user, org: org} ->
5 Membership.membership_changeset(%Membership{}, user, org, %{user_role: "creator"})
6 end)
7 |> Repo.transaction(
Some key notes for those who might need them...
cast_assoc
failed because of NOT NULL CONSTRAINT
on JOIN TABLE field
Originally, the User.registration_changeset
was this way because I was trying to make use of Phoenix's nested form handlers:
1 def registration_changeset(user, attrs, opts \ []) do
2 user
3 |> cast(attrs, [:first_name, :last_name, :email, :password])
4 |> cast_assoc(:organisations, required: false )
5 |> validate_email()
6 |> validate_password(opts)
7 end
When I submitted the form, the database would throw the following error.
ERROR 23502 (not_null_violation) null value in column "user_role" violates not-null constraint
The culprit was Line 4, the cast_assoc
, but digging deeper, it's because of the many-to-many association between User and Organisation that was defined by this:
many_to_many :users, App.Accounts.User,
join_through: App.Organisations.Membership
As mentioned, the error was thrown by the database and that's because cast_assoc
automatically creates the INSERT
for the JOIN TABLE. However, because user_role
has a NOT NULL
constraint, an error was thrown because user_role
was not set. And from what I've learned, cannot be set when using cast_assoc
. Hence the need for separate INSERT
s.
I first tried relaxing the constraint by removing the NOT NULL
from the user_role
field in the database and used the same Multi Transaction above, I was not a fan of doing this because it risks data integrity. What ended up happening, however, was two records for an organisation and a membership were being created – a record each for cast_assoc
and a record each for the INSERT
in the Multi Transaction.
So, it showed me clearly that cast_assoc
was not needed (and should not be used) at all if I were breaking down the operation into multiple INSERT
s. It also forced me to look for another way (see below) and also enabled me to (happily) restore the NOT NULL
constraint.
embedded_schemas instead of nested forms
This is not to knock what the Phoenix Framework has provided to make things more convenient for us via nested forms but to demonstrate when it should be used. When I say nested forms, I'm referring to this guide titled Polymorphic associations with many to many. In this guide, it details an example of a simple Todo List. Simple in the sense that there the JOIN TABLE does not have additional fields to be set, let alone a NOT NULL
constraint on one of those fields. In my particular case, a nested form simply would not work.
Similarly, there's an argument that modeling forms this way is coupling it too closely with our database schema, which can lead to other complications down the road. So, in general, decouple I suppose!
I could write more, but I think this sufficiently covers the main areas. If you have any questions, feel free to leave a comment.
Cheers!