2
votes

Introduction

I have a problem with relations in Ecto. I have two schemas User and Role, and I want to be able to insert a new User with a foreign key in DB to Role table.

The problem is every time I try to insert a new user, it doesn't have a relation to the Role table (role_id column is null in User table). I tried to build an assoc in order to create the relation but it doesn't work.


Current code

My User schema is:

  schema "user" do
    field :first_name, :string
    field :last_name, :string
    field :email, :string
    field :encrypted_password, :string
    field :password, :string, virtual: true

    belongs_to :role, TestApp.Role

    timestamps()
  end

Role schema:

  schema "role" do
    field :name, :string

    timestamps()

    has_many :users, TestApp.User
  end

I defined the User changeset as the following:

  @required_fields ~w(first_name last_name email password)
  @optional_fields ~w(encrypted_password)

  @required_update_fields ~w()
  @optional_update_fields ~w(first_name last_name email password encrypted_password role)


  def create_changeset(struct, params \\ :empty) do
    changeset(struct, params, @required_fields, @optional_fields)
  end

  def update_changeset(struct, params \\ :empty) do
    changeset(struct, params, @required_update_fields, @optional_update_fields)
  end

  defp changeset(struct, params \\ :empty, required_field, optional_fiels) do
    struct
    |> cast(params, required_field, optional_fiels)
    |> validate_format(:email, ~r/@/, message: "invalid format")
    |> validate_length(:password, min: 5)
    |> validate_confirmation(:password, message: "password does not match")
    |> unique_constraint(:email, message: "email already taken")
    |> generate_encrypted_password
  end

And Role changeset:

  def changeset(struct, params \\ %{}) do
    struct
    |> cast(params, [:name])
    |> validate_required([:name])
    |> unique_constraint(:name)
  end

In my UserController, I have a create method:

  def create(conn, %{"user" => user_params}) do
    changeset = User.create_changeset(%User{}, user_params)

    case User.insert(changeset) do
      {:ok, user} ->
        conn
        |> put_status(:ok)
        |> render(TestApp.UserRender, "show.json", user: user)
      {:error, changeset} ->
        handle_user_creation_validation_error(conn, changeset: changeset)
    end
  end

Tested solutions (without success)

I tested several options, the following list it's just an example of what I tried so far (too many tries). It doesn't have to compile at all, i'll just post it as information.

I tried to create a insert method inside User to build an assoc and save the changeset.

  def insert(changeset) do
    if changeset.valid? do
      IEx.pry
      TestApp.Role.find_by_name("user")
      |> Ecto.build_assoc(:users)
      |> Ecto.change(changeset.params)
      |> Repo.insert
    else
      {:error, changeset}
    end
  end 

I tried also get the role and build an assoc

TestApp.Role.find_by_name("user")
|> TestApp.Repo.preload(:users)
|> Ecto.build_assoc(:users)
|> Ecto.Changeset.put_assoc(:users, user)
|> Repo.insert!

I saw this example in the Ecto documentation

  def insert(changeset) do
    Repo.transaction fn ->
      TestApp.Role.find_by_name("user")
      |> TestApp.Repo.preload(:users)
      |> Ecto.Changeset.put_assoc(:users, [changeset])
      |> Repo.insert
  end

Current status

I was able to create relations between users and roles, but every time I inserted a new user a new role role was inserted also (I was shocked, it just create a one_to_one relation every time!).

I know there is a right way to this, but I wonder if it will imply a preload of :users from a role first. I don't think that it's an acceptable solution, hope someone could explain all this about relations, preloads and saving the changesets.

Solution

Well, seems that it's necessary send the FK as a param in the User changeset, so I had to change the param field role with role_id.

A param body request example might be:

{
    "user" : {
        "first_name": "first name",
        "last_name": "last name",
        "email": "test@email",
        "password": "longtestpassword",
        "password_confirmation": "longtestpassword",
        "role_id": 1
    }
} 

I added also a cast_assoc to the changeset validation pipeline:

|> cast_assoc(:role)

The User.insert/1 implementation was removed and the controller calls Repo.insert/1 instead.


Thank you for your help!

2

2 Answers

0
votes

Change

@optional_update_fields ~w(... role)

to:

@optional_update_fields ~w(... role_id)

and then send just role_id in your params instead of map of role fields

0
votes

Usually I use these way to deal this situation:

From user side:

  1. directly put the role_id(which is certain Role.id) in User changese attrs, so after Repo.insert(user), this new user will have ForeignKey of Role, like this:
def changeset(%User{}=user, %{.., role_id: certain_role_id}=attrs) do
  user 
  |> cast(attrs, [...,:role_id]
  |> validate_require([:role_id, ...])
  1. build_assoc, in fact works like last one, just push "role_id" into user, but more beautiful..

From role side:

  1. put_assoc, used in the Role changeset, it will manager all users associated with current role by put_assoc(:users, [user_changeset_lists]).Due to the put_assoc do not check the data, so better to do changeset manual;,like this:
def changeset(%Role{}=role, new_user) do
  users = Repo.preload(role, :users)
  users = user ++ User.changeset(new_user)
  role
  |> cast(..)
  |> validate_required(..)
  |> put_assoc(:users, users)
end
  1. cast_assoc like add user for role without affectting alreasy related user, and cast_assoc will auto invoke User.changeset, like this:
def changeset(%Role{}=role, %{users: [new_user1, new_user2]}) do
  role
  ...
  |> cast_assoc(:users)
end