Can we make a belongs_to optional?

So I have users and roles tables.

user may have one role, but optional, role_id may be null in database.

I added this to my migration:

alter table(:users) do
  add :role_id, references(:users)
  add :role_id, references(:roles)
end

then,
inside users schema:

has_one(:role, Wshop.Accounts.Role)

inside roles schema:

has_many(:users, Wshop.Accounts.User)

then I put this on user’s changeset

user
    |> cast(attrs, [:name, :username, :password, :email, :password_confirmation])
    |> validate_required([:name, :username, :password, :email, :password_confirmation])
    |> put_assoc(:role, attrs.role)

I want to assign it, for example with this changeset:

{:ok, role} = Repo.insert %Role{name: "admin", description: "this is admin"}
%{
  name: Faker.Name.name,
  password: "somepassword",
  password_confirmation: "somepassword",
  username: Faker.Internet.user_name,
  email: Faker.Internet.email(),
  role: role
}

The problem is, the :role here is not optional, you have to provide it in the changeset map.

** (KeyError) key :role not found in: %{"email" => "ahmed1993@batz.biz", "name" => "Rebecca Predovic", "password" => "somepassword", "password_confirmation" => "somepassword", "role" => nil, "username" => "kirstin_skiles"}

How could I make the role optional? Any help would be appreciated :slight_smile:

Did you set null: true in your migration on that field?

It’s this line that explodes. You probably want |> cast_assoc(:role)

To go into a bit more detail; It’s not even the put_assoc failing, but the attempted map retrieval.

iex(1)> attrs = %{"email" => "ahmed1993@batz.biz", "name" => "Rebecca Predovic", "password" => "somepassword", "password_confirmation" => "somepassword", "role" => nil, "username" => "kirstin_skiles"}
iex(2)> attrs.role
** (KeyError) key :role not found in: %{"email" => "ahmed1993@batz.biz", "name" => "Rebecca Predovic", "password" => "somepassword", "password_confirmation" => "somepassword", "role" => nil, "username" => "kirstin_skiles"}
3 Likes

If You prefer to keep put vs cast, You could do

defp maybe_put_assoc(changeset, _assoc, nil), do: changeset
defp maybe_put_assoc(changeset, assoc, value), do: put_assoc(changeset, assoc, value)

And call it like this… because as mentionned by @LostKobrakai, attrs.role will fail if the key is not present.

user
    |> cast(attrs, [:name, :username, :password, :email, :password_confirmation])
    |> validate_required([:name, :username, :password, :email, :password_confirmation])
    |> maybe_put_assoc(:role, Map.get(attrs, :role))

I already try this in my migration

add :role_id, references(:roles), null: true

the result is the role_id is already NULL at the database.

but the problem above persists.

cast_assoc means that I need to build the new association.

But the problem is, what if the association is already there.

Like, how could you do this in Elixir:

# a role was created one week ago
# this is a ruby example
user.role = role
user.save

@LostKobrakai I think you’re right, I didn’t realize that.

That’s because the keys are binaries, not atoms. attrs.email, for example, will also fail:

iex(1)> attrs = %{"email" => "ahmed1993@batz.biz", "name" => "Rebecca Predovic", "password" => "somepassword", "password_confirmation" => "somepassword", "role" => nil, "username" => "kirstin_skiles"}
iex(2)> attrs.email
** (KeyError) key :email not found in: %{"email" => "ahmed1993@batz.biz", "name" => "Rebecca Predovic", "password" => "somepassword", "password_confirmation" => "somepassword", "role" => nil, "username" => "kirstin_skiles"}

iex(2)> attrs["email"]
"ahmed1993@batz.biz"
iex(3)> attrs["role"]
nil