Build_assoc and UUID

Hi, I’ve been scratching my head around this for a while and trying different combinations, but I’m just stuck whilst using build_assoc which does not recognise binary id UUIDs in my code. Here’s my problem and question.

I’m working on a banking app, so I have a user and wallet. user was created with Pow if that makes any difference. There is a has_many relationship between user and wallet. Here are the schemas:

use MyApp.Schema

schema "users" do
  field :role, :string, null: false, default: "user"
  has_many :wallets, MyApp.Accounts.Wallet
  pow_user_fields()
  timestamps()
end

and

use MyApp.Schema
...
schema "wallets" do
  field :description, :string
  field :balance, Money.Ecto.Composite.Type
  belongs_to :user_id, MyApp.Users.User, references: :uuid, type: :binary_id
  timestamps()
end

where MyApp.Schema is set up for UUIDs:

defmodule MyApp.Schema do
  defmacro __using__(_) do
    quote do
      use Ecto.Schema
      @primary_key {:uuid, :binary_id, autogenerate: true}
      @foreign_key_type :binary_id
      @derive {Phoenix.Param, key: :uuid}
      @timestamps_opts
    end
  end
end

Then in my CreateWallet migration:

defmodule MyApp.Repo.Migrations.CreateWallets do
  use Ecto.Migration

  def change do
    create table(:wallets) do
      add :description, :string, null: false
      add :balance, :money_with_currency, null: false
      add :user_id, references(:users, column: :uuid, type: :binary_id, on_delete: :nothing)
      timestamps()
    end

    create index(:wallets, [:user_id])
  end
end

All standard I think so far. However, if I want to implement a create_user_wallet function:

def create_user_wallet(%User{} = user, %{} = wallet_params) do
  wallet = Ecto.build_assoc(user, :wallets, wallet_params)
  Repo.insert(wallet)
end

and then try to run it, there is an error:

** (KeyError) key :user_uuid not found
    (ecto 3.7.1) lib/ecto/association.ex:754: Ecto.Association.Has.build/3

This is fine if it’s referring to the uuid key in the user map, as I don’t see user_uuid there, it’s just %User{uuid: "1234..."} - or is it referring to user_id in the wallet database? However, why should the build_assoc function expect that and not function as is given the Schema implementation and how can I override it, or set up my schemas and/or own Schema implementation and/or migration correctly?

Thanks for reading this far!

The problem is in your wallets schema. You’ve defined that each wallet belongs to an user_id but it should be just user.

Thanks @krasenyp - I changed it as you say but the error message still is the same. I think this is because it is the :foreign_key parameter for the belongs_to macro, but what I do not fully understand is why is this not already captured in the implementation of UUIDs in MyApp.Schema? I also changed it to belongs_to :user_uuid in the wallet schema. This now gets past the error message. However, in the docs for belongs_to the macro also adds the suffix _id to the parameter for the :foreign_key and this is now what I see after running

wallet = Ecto.build_assoc(user, :wallets, wallet_params)
# => %MyApp.Accounts.Wallet{
#        __meta__: #Ecto.Schema.Metadata<:built, "wallets">,
#        balance: #Money<:GBP, 0>,
#        description: "GBP",
#        user_uuid: "bbf14364-3808-48b6-b124-a16b7a24fd0b",
#        user_uuid_id: nil,
#        uuid: nil
# }

So, there is something unexpected going on with the user_uuid_id field - I expected this field to be populated, and not user_uuid.

And when I try to insert this in the database with Repo.insert(wallet), I get a new error message about invalid user_uuid field in the changeset.

{:error,
 #Ecto.Changeset<
   action: :insert,
   changes: %{
     balance: #Money<:GBP, 0>,
     description: "GBP",
   },
   errors: [user_uuid: {"is invalid", [type: :map]}],
   data: #MyApp.Accounts.Wallet<>,
   valid?: false
 >}

This kind of makes sense, that should not be there as the valid foreign key should be user_uuid_id if I read the docs correctly? Am I missing something else that’s obvious? Thanks

I think in your migration primary key should be false and add a new field for uuid and put primary key true. Also in your schema change this

belongs_to :user_id, MyApp.Users.User, references: :uuid, type: :binary_id

to this

belongs_to :user, MyApp.Users.User

And then you can pass user_id in the changeset.

Thanks @siddhant3030 , I forgot to add that I already have in my config.exs:

config :myapp, MyApp.Repo, migration_primary_key: [name: :uuid, type: :binary_id]

so uuid's are being correctly generated as primary keys in the wallet and user tables.

The errors you are getting suggest that you have a column named user_uuid on the wallets table. You’ll need to tell the belongs_to / has_many machinery about that:

# in the user schema
has_many :wallets, foreign_key: :user_uuid

# in the wallet schema
belongs_to :user, foreign_key: :user_uuid, ...etc
2 Likes

Thanks @al2o3cr ! This now works when I also changed my migration to

create table(:wallets) do
  ...
  add :user_uuid, references(:users, column: :uuid, type: :binary_id, on_delete: :nothing)
 ..
end

This raises the question of what these statements actually do in MyApp.Schema and config of MyApp.Repo. It seems I would be better off being explicit with uuid everywhere?

  # in MyApp.Schema
  @primary_key {:uuid, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  @derive {Phoenix.Param, key: :uuid}
``
Seems I should just keep using `id` as a suffix when using binary ids.

I wanted to point the things in MyApp.Schema out to you yesterday :slight_smile:. So if you set :uuid as the field name for the @primary_key, Ecto will use that as the suffix in reference field names. That’s why you get user_uuid in your Wallet schema. See Ecto.Association.association_key/2.

@foreign_key_type :binary_id is correct. Maybe @derive {Phoenix.Param, key: :uuid} is too general to be inside the Schema macro. Unless you can override it in individual schemas, but I don’t know if Elixir allows you to do that.

Considering you have the :migration_primary_key configured, adding a column can/should be much shorter:

add :user_uuid, references(:users)

Yep, looks like a lot of trouble to have the uuid name/suffix everywhere. I would just go with id and set its type to :binary_id. :+1: Unless maybe you intend to have tables with integer ids?

Thanks @aziz ! This is clear now, makes sense, cheers!