Ecto where to place put_assoc/4 when I insert child and why we need to preload if we know id?

I’m quite new to Elixir and Phonex but I felt in love with it so much. After books (Programming Elixir |> 1.6 and Programming Phoenix |> 1.4) and docs I read through so many posts and threads about using cast_assoc/3 and put_assoc/4 but still have issues with it usage and fully understand.

I saw this post on medium about using only id's without assocs, but can’t see this approach confirmed as good in docs or threads.

I’m building GraphQL API with Phoenix + Absinthe (still didn’t touch) for my translation company’s CRM.

I have Manager, Performer and Client entities. All of them belongs to User, because Manager can be Performer too. Performer required to have user, country, native language and some other references.

Migrations:

    create table(:users) do
      add :first_name, :string, null: false
      add :last_name, :string, null: false
      add :middle_name, :string
      add :phone, :string, null: false
      add :email, :string, null: false
    end

    create unique_index(:users, [:phone])
    create unique_index(:users, [:email])

    create table(:performers) do
      add :city, :string, null: false
      add :bio, :text
      add :birthday, :date, null: false
      add :user_id, references(:users, on_delete: :delete_all), null: false
      add :country_id, references(:countries), null: false
      add :native_language_id, references(:languages), null: false
      add :gender_id, references(:genders), null: false
      add :status_id, references(:performer_statuses), null: false
    end

    create unique_index(:performers, [:user_id])

Schemas:

User

defmodule MyApp.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  @required_fields ~w(first_name last_name phone email)a
  @optional_fields ~w(middle_name)a

  schema "users" do
    field :first_name, :string
    field :last_name, :string
    field :middle_name, :string
    field :email, :string
    field :phone, :string

    timestamps()
  end

  @doc false
  def changeset(user, attrs) do
    user
    |> cast(attrs, @required_fields ++ @optional_fields)
    |> validate_required(@required_fields)
    |> validate_format(:phone, ~r/^[[:digit:]]+$/)
    |> validate_format(:email, ~r/@/)
    |> unique_constraint(:phone)
    |> unique_constraint(:email)
  end
end

Performer:

defmodule MyApp.Production.Performer do
  use Ecto.Schema
  import Ecto.Changeset
  alias MyApp.Accounts.User
  alias MyApp.Production.{PerformerStatus, Gender}
  alias MyApp.Reference.{Country, Language}

  @required_fields ~w(city birthday)a
  @optional_fields ~w(bio)a

  schema "performers" do
    field :bio, :string
    field :birthday, :date
    field :city, :string

    belongs_to :user, User
    belongs_to :gender, Gender
    belongs_to :country, Country
    belongs_to :native_language, Language
    belongs_to :status, PerformerStatus

    timestamps()
  end

  @doc false
  def changeset(performer, attrs) do
    performer
    |> cast(attrs, @required_fields ++ @optional_fields ++ [:user_id])
    |> shared_changeset(attrs)
  end

  @doc false
  def changeset_with_existing_user(performer, attrs) do
    performer
    |> cast(attrs, @required_fields ++ @optional_fields)
    |> shared_changeset(attrs)
    |> unique_constraint(:user_id)
    |> assoc_constraint(:user)
  end

  defp shared_changeset(performer, attrs) do
    performer  
    |> validate_required(@required_fields)
    |> put_assoc(:user, attrs.user)
    |> put_assoc(:gender, attrs.gender)
    |> put_assoc(:country, attrs.country)
    |> put_assoc(:native_language, attrs.native_language)
    |> put_assoc(:status, attrs.status)
  end
end

I have 2 clauses in API for creating Performer - when User exists and when it’s not. Gender, Country, Language and PerformerStatus are

  def create_performer(attrs \\ %{})

  def create_performer(%{user: %{id: id}} = attrs) do
    user = Accounts.get_user!(id)

    %Performer{}
    |> Performer.changeset_with_existing_user(%{attrs | user: user})
    |> Repo.insert()
  end

  def create_performer(attrs) do
    %Performer{}
    |> Performer.changeset(attrs)
    |> Repo.insert()
  end

Now I’m struggling with right way of populating Performer’s changeset with all other existing references. My changeset has so much put_assoc/4 rows, It looks like I do something wrong and I will have more associations further (1-Many and Many-Many), because creating performer would be all-in-one page with form where user fill everything.

The second strange thing - is necessity of preloading everything with put_assoc/4 for update or create. I could get entity with all the foreign keys filled from API client and why I need to preload everything? It’s looks like performance issue for me, it costs so much time if entity has so many associations.

And AFAIK - put_assoc/4 preferred to use with existing data, cast_assoc/3 to cast changeset for creating new (but it could match id and be used for update existing entity).

What I’m doing wrong?

May be I need to cast all foreign keys to changeset and don’t use associations at all? (But it’s not recommended to validate_required/3 associations)
Or I need to use build_assoc/3? But from what entity I need to build then if I have so many belongs_to?
Or I need to move all put_assoc/4 inside Context API?

I’m stuck a little and would appreciate advice or suggestion for getting things done in proper way :slight_smile:

Thanks, have a nice day.

At this moment I’ve finished with following set of Performer changesets:

  @required_fields ~w(city birthday gender_id country_id native_language_id status_id user_id)a
  @optional_fields ~w(bio)a

  def changeset(performer, attrs) do
    performer
    |> cast(attrs, @required_fields ++ @optional_fields)
    |> validate_required(@required_fields)
    |> unique_constraint(:user_id)
    |> common_changeset()
  end

  def changeset_with_new_user(performer, attrs) do
    performer
    |> cast(attrs, @required_fields ++ @optional_fields)
    |> validate_required(@required_fields -- [:user_id])
    |> cast_assoc(:user, required: true)
    |> common_changeset()
  end

  def update_changeset(performer, attrs) do
    performer
    |> cast(attrs, @required_fields ++ @optional_fields)
    |> validate_required(@required_fields)
    |> cast_assoc(:user)
    |> unique_constraint(:user_id)
    |> common_changeset()
  end

  defp common_changeset(changeset) do
    changeset
    |> assoc_constraint(:gender)
    |> assoc_constraint(:country)
    |> assoc_constraint(:native_language)
    |> assoc_constraint(:status)
  end

In context layer using them as following:

  def create_performer(attrs) do
    %Performer{}
    |> Performer.changeset(attrs)
    |> Repo.insert()
  end

  def create_performer_with_new_user(attrs) do
    %Performer{}
    |> Performer.changeset_with_new_user(attrs)
    |> Repo.insert()
  end

  def update_performer(%Performer{} = performer, attrs) do
    performer
    |> Repo.preload(:user)
    |> Performer.update_changeset(attrs)
    |> Repo.update()
  end

At this moment I stand on creating user with populating ids manually, because forms from frontend would send everything with populated ids. Can’t get motivation to preload entity when it could go only with id from API client.

Would appreciate to get any advices, thanks.

There are examples of put_change usage in the official Phoenix docs. I think there is nothing wrong with that if you don’t want to make any change to the entity which you have its id.

So for example you can check this example from the Context docs:

def create_page(%Author{} = author, attrs \\ %{}) do
  %Page{}
  |> Page.changeset(attrs)
  |> Ecto.Changeset.put_change(:author_id, author.id)
  |> Repo.insert()
end

def ensure_author_exists(%Accounts.User{} = user) do
  %Author{user_id: user.id}
  |> Ecto.Changeset.change()
  |> Ecto.Changeset.unique_constraint(:user_id)
  |> Repo.insert()
  |> handle_existing_author()
end
defp handle_existing_author({:ok, author}), do: author
defp handle_existing_author({:error, changeset}) do
  Repo.get_by!(Author, user_id: changeset.data.user_id)
end

Hope it’s helpfull.

2 Likes

Thanks, @Kurisu.

As I understood from docs and books - put_assoc/4 and cast_assoc/3 are useful when I want to update some fields in associations alongside updating main entity. In my case I won’t touch other entities except user, which I cast_assoc/3 (changeset function is proper place for it or better place it in context layer?) in changeset. For me this cods isn’t looks good anyway and I hope it will evolve with me :smiley:

1 Like

Ah I see… So maybe you can have different changesets for each scenario?
This way you won’t have to preload associations that are not needed in a given situation.