Search code examples
elixirschemaphoenix-frameworkectophoenix-live-view

Elixir Ecto Issue - Can't make an association between two tables?


I am trying to make an association between table.

I currently have a "users" table with the following fields:

id email password first_name last_name role
1 [email protected] hash Bob White teacher
2 [email protected] hash Joe Brown student

I am trying to implement two new user types: teachers and students

As such, I want to make two different tables for both user types which will reference the main table like so:

  • teachers table
id user_id first_name last_name
1 1 (reference to "users") Bob White
  • students table
id user_id first_name last_name
1 2 (reference to "users") Joe Brown

I generated a new migration and created the "teachers" table like so:

create table("teachers") do
   add :user_id, references("users")
   add :first_name, :string
   add :last_name, :string
end

I then defined a schema for the teachers table like this:

schema "teachers" do
   field :first_name, :string
   field :last_name, :string

   belongs_to :user, User

Lastly, I added this to the user schema:

   has_many :teachers, Teacher

I have now successfully added the teachers table with my desired columns. The problem is, my registration form is only accommodating one changeset. The user schema does all the validation and adds the user into the “users” table when the registration is successful. But the “teachers” table remains empty. What would be the best practice to populate the teachers table with the registering user’s info?


Solution

  • The first thing to know is there is no one "best practice" for this.

    In my experience, there are 2 different approaches:

    1. Use cast_assoc in your User.changeset function.
    2. In your create_user function, use Ecto.Multi to create the :user and then use this user ID to create the teacher or student.

    Neither of these 2 is "better". Use whichever one feels better for you.

    I'll explain both approaches in more detail now:

    1. the cast_assoc approach

    In your accounts.ex context, you would have:

    def create_user(attrs) do
      %User{}
      |> User.creation_changeset(attrs)
      |> Repo.insert()
    end
    

    The attrs map would look like this:

    %{
      password: "unhashed-password"
      student: %{
        first_name: "Bob",
        last_name: "Smith"
      }
    }
    

    or this:

    %{
      password: "unhashed-password"
      teacher: %{
        first_name: "Bob",
        last_name: "Smith"
      }
    }
    

    I decided to create a changeset function just for creating a user, because maybe I sometimes want to just update a user without updating the associated teacher/student. So, in user.ex file, I have this:

    def creation_changeset(user, attrs) do
      User
      |> cast(attrs, @user_fields_to_cast)
      |> cast_assoc(:teacher, with: &Teacher.user_creation_changeset/2)
      |> cast_assoc(:student, with: &Student.user_creation_changeset/2)
    end
    

    And that's pretty much it.

    The create_user function should return a {:ok, %User{}} (or {:error, %Ecto.Changeset{}}) tuple with the teacher and student assocs loaded on the user (either teacher or student should be nil).

    You don't have to use dedicated "creation" changesets, but I have found it usually makes this easier.

    2. The Ecto.Multi approach

    If you're doing this, there's no need to use cast_assoc and you can leave the User/Teacher/Student schemas as they are. Instead, the context function will look something like this:

    alias Ecto.Multi
    
    def create_user(attrs) do
      {teacher_attrs, attrs} = Map.pop(attrs, :teacher)
      {student_attrs, attrs} = Map.pop(attrs, :student)
    
      Multi.new()
      |> Multi.insert(:user, User.changeset(%User{}, attrs)
      |> Multi.run(:teacher, fn repo, %{user: user} ->
        if teacher_attrs do
          %Teacher{}
          |> Teacher.changeset(Map.put(teacher_attrs, :user_id, user.id))
          |> repo.insert()
        else
          {:ok, nil}
        end
      end)
      |> Multi.run(:student, fn repo, %{user: user} ->
        if student_attrs do
          %Student{}
          |> Student.changeset(Map.put(student_attrs, :user_id, user.id))
          |> repo.insert()
        else
          {:ok, nil}
        end
      end)
    end
    

    and this will return (in the happy path), this:

    {:ok, %{user: user, student: student, teacher: teacher}}
    

    and either student or teacher will be nil.

    OK I'm done.

    I typed all this right in the browser without testing anything so beware typos, unmatched brackets and other small problems! It's just meant to be illustrative.