Associations in Phoenix - country is not being associated with my user

I have a users table and a countries table as follow:

    field :name, :string
    field :code, :string

    has_many :users, MyApp.Accounts.User
  end
    field :password_hash, :string
    field :email, :string
    field :password, :string, virtual: true
    field :password_confirmation, :string, virtual: true
    field :first_name, :string
    field :last_name, :string
    field :gender, :string
   (…)

    belongs_to :country, MyApp.Country, on_replace: :nilify

    timestamps()
  end

A country has_many users and user belongs to country. I want to add country_id to user as an update action. My changeset:

	country = attrs["country"]
	user
    |> cast(attrs, [:first_name, :last_name, :gender, :birth_date, :country_id])
    |> put_assoc(:country, country)
    |> assoc_constraint(:country)

I can update first_name, last_name, gender etc but Country is not being associated with my user. Nothing happens and it remains nil:

Hestia.Accounts.User{
    __meta__: #Ecto.Schema.Metadata<:loaded, "users">,

    country: #Ecto.Association.NotLoaded<association :country is not loaded>,
    country_id: nil,
    email: "rodrigo@example.com",
    first_name: "Rod",
    gender: "male",
    (…)
    updated_at: ~N[2019-12-28 17:33:43.813377]
  }

It’s the first time I am trying to use associations in Phoenix. What am I doing wrong?

Thanks

1 Like

Can you post the code that’s calling this changeset? Is it passing country_id or country? My guess is that it’s passing country_id but the put_assoc after the cast is overwriting the change…

1 Like

Hello, thanks for the quick reply. I manage to make it work partially. Let me explain:
My controller:

 def update_profile(conn, data) do

    user = data["user"]
    country = Repo.get_by!(Country, name: data["user"]["country"]["name"])

    country_map = %{
      name: country.name,
      code: country.code,
      id: country.id
    }

    old_user = Accounts.get_user(user["id"])
    new_user = Map.put(user, "country", country_map)
    changeset = User.edit_profile_changeset(old_user, new_user)
    with {:ok, %User{} = user} <- Accounts.update_user_profile(old_user, changeset.changes) do
      render(conn, "show.json", user: old_user)
    end
  end

Template. I have inputs for gender, name etc in the same form and they are working.

<%= form_for @edit_profile_changeset,  Routes.profile_path(@conn, :update_profile), fn f -> %>
(...)
<%= inputs_for f, :country, fn ff -> %>
     <%= select ff, :name, @countries, class: "form-control" %>
<% end %>
(...)
<%= submit "Update", class: "btn form-btn w-100" %>

My user.ex:

  def edit_profile_changeset(user, attrs) do
    user
    |> cast(attrs, [:first_name, :last_name, :gender, :birth_date])
    |> put_assoc(:country, Repo.get_by!(Country, name: "Portugal"))
end

User is now being updated with only country_id: Country is still "not loaded"

%MyAPP.Accounts.User{
  __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
  avatar: nil,
  birth_date: nil,
  country: #Ecto.Association.NotLoaded<association :country is not loaded>,
  country_id: 180,
  email: "rodrigo@example.com",
  first_name: "Rodrssss",
  gender: "male",
  (...)
  updated_at: ~N[2019-12-28 21:01:41.144460]
}

FWIW, if you change this to instead use country_id you can avoid nearly all of the custom code that using name needs.

Usually this is a symptom that something isn’t being preloaded correctly, but trying it out locally I get the following behaviors that raise questions:

  • calling put_assoc on an association that hasn’t been preloaded fails with an error. Does Accounts.get_user/1 do preloading?

  • making a changeset with put_assoc and then passing that to Repo.update returns a result with the association correctly loaded. What does Accounts.update_user_profile/2 do? Usually you’d pass the whole changeset in, not changeset.changes.

1 Like

Hello! Thanks again!

This is Accounts.get_user/1:

def get_user(id) do
    user = Repo.get(User, id)
    country_query = from f in MyApp.Country
    query = from u in User,
            where: u.id == ^id,
            preload: [country: ^country_query]
    MyApp.Repo.one!(query)
  end

This is Accounts.update_user_profile/2:

def update_user_profile(%User{} = user, attrs) do
    user
    |> User.edit_profile_changeset(attrs)
    |> Repo.update()
 end

I don’t 100% follow what the intention of country_query and related code is; this function appears to be equivalent to:

def get_user(id) do
  user = Repo.get!(User, id)
  Repo.preload(user, :country)
end

This shorter function still:

  • raises if id doesn’t correspond to a User record
  • preloads the country association

These two functions can be shortened as well - note that the controller makes a changeset then extracts the changes from that to pass them back to the same changeset function. Also note that while the incoming parameters are string-keyed (%{"first_name" => etc}) the values in changes are going to be atom-keyed (%{:first_name => etc}) - cast will accept either, but any code you add needs to be aware of the difference.

# in the context
def update_user_profile(%User{} = user, attrs) do
  user
  |> User.edit_profile_changeset(attrs)
  |> Repo.update()
end

# in the controller
def update_profile(conn, %{"user" => user_params}) do
  user = Accounts.get_user(user_params["id"])

  country = Repo.get_by!(Country, name: user_params["country"]["name"])

  new_params = Map.put(user_params, "country", country)

  with {:ok, %User{} = updated_user} <- Accounts.update_user_profile(user, user_params) do
    render(conn, "show.json", user: updated_user)
  end
end

Some observations/questions:

  • the original update_profile was rendering old_user not the updated one, was that on purpose?
  • Repo.get_by! will fail loudly if passed nil. Is that a desirable behavior if the user forgets to select a country?
  • in most situations, the ID of the thing to be updated would either be passed in via the URL + router (so /users/:id/update_profile or similar) or derived from the session (if users can only update their own profile). Putting the ID in data["user"] alongside first_name etc is an unusual choice.
1 Like

Your last reply solved the whole problem. Thanks a lot and happy new year!