beam-community / ex_machina

Create test data for Elixir applications
https://hex.pm/packages/ex_machina
MIT License
1.91k stars 145 forks source link

[QUESTION] How do I insert associations with `has_many through`? #469

Open dkarter opened 2 weeks ago

dkarter commented 2 weeks ago

I read through the code and documentation but can't figure out how to insert a has_many through association using ExMachina.

I have the following schema:

Account:

defmodule ShareStream.Accounts.Account do
  use Ecto.Schema

  schema "accounts" do
    field :name, :string

    has_many :account_users, AccountUser
    has_many :users, through: [:account_users, :user]

    timestamps(type: :utc_datetime)
  end
end

User:

defmodule ShareStream.Accounts.User do
  use Ecto.Schema

  schema "users" do
    field :email, :string
    field :password, :string, virtual: true, redact: true
    field :hashed_password, :string, redact: true
    field :current_password, :string, virtual: true, redact: true
    field :confirmed_at, :utc_datetime

    has_many :account_users, AccountUser
    has_many :accounts, through: [:account_users, :account]

    timestamps(type: :utc_datetime)
  end
end

AccountUser:

defmodule ShareStream.Accounts.AccountUser do
  use Ecto.Schema

  schema "account_users" do
    belongs_to :account, Account, type: :string
    belongs_to :user, User, type: :string

    timestamps(type: :utc_datetime)
  end
end

Here's my factory:

defmodule ShareStream.Factory do
  use ExMachina.Ecto, repo: ShareStream.Repo

  alias ShareStream.Accounts.Account
  alias ShareStream.Accounts.AccountUser
  alias ShareStream.Accounts.User

  def user_factory(attrs) do
    password = Map.get(attrs, :password, "password1234")

    %User{
      email: sequence(:email, &"email-#{&1}@example.com"),
      password: password,
      hashed_password: User.hash_password(password)
    }
    |> merge_attributes(attrs)
    |> evaluate_lazy_attributes()
  end

  def account_factory(attrs) do
    %Account{
      name: Faker.Company.name()
    }
    |> merge_attributes(attrs)
    |> evaluate_lazy_attributes()
  end

  def account_user_factory(attrs) do
    %AccountUser{}
    |> merge_attributes(attrs)
    |> evaluate_lazy_attributes()
  end
end

Pretty common setup I think.

I'm trying to insert an account with associated users:

insert(:account, users: [%{email: "foo@example.com"}])

I'm hoping this will automatically insert the connecting table without having to explicitly specify that.

The test code for ExMachina contains a fixture with a has_many through for editors on User: https://github.com/beam-community/ex_machina/blob/6663050ef2f8fe0f3a1109451689d30f3dcb46b4/test/support/models/user.ex#L12

However I couldn't find any test that uses that field.

Seems like it's looking for a key that's no longer there? I'm using Ecto 3.11.2 and EctoSQL 3.11.3

CleanShot 2024-08-26 at 20 17 55@2x

pfac commented 2 weeks ago

Not one of the maintainers and I may be completely wrong, but let's see if I can help.

I don't remember seeing that specific error, and I'm assuming a lot of stuff since I don't know the underlying database schema, so apologies if I'm completely off.

Attributes passed to factories are expected to be the final attributes for the struct. Looking at your use of factories, when you run:

insert(:account, users: [%{email: "foo@example.com"}])

what you're actually doing is creating the following account:

%Account{
  # other fields
  users: [%{email: "foo@example.com"}]
}

Notice the list under :users is just being put in the struct, not being passed down to another factory. ExMachina makes no assumptions on how you want to build the dependencies. Also, even if you were to pass an actual user struct, this would not create the account-user association.

So you need to actually create the Account and the User, and then the AccountUser, and then preload it from the account:

account = insert(:account)
user = insert(:user, email: "foo@example.com")
_account_user = insert(:account_user, account: account, user: user)

# this will load the users into the account struct
account = Repo.reload(account, :users)

I personally prefer to use pipes as described in the docs, and have something like with_user handle the details of creating the user, and linking it to the account:

insert(:account)
|> with_user(email: "foo@example.com")

# and in the factories

def account_factory
  # ...
end

def with_user(%Account{id: id} = account) when not is_nil(id) do
  # if ID is not nil, assume it is persisted
  user = insert(:user, email: "foo@example.com")
  account_user = insert(:account_user, account: account, user: user)

  account
  |> Repo.preload([:account_users, :users])
  |> Map.update!(:account_users, &[account_user | &1])
  |> Map.update!(:users, &[user | &1])
end

Bonus: Schemas

May be completely unrelated, but I noticed some possibly weird things in your schemas.

Looking at all your schemas, you did not override the primary key definition, so I would assume they are using the integer ID as the primary key. But on AccountUser you override the belongs_to type to use :string.

I would expect AccountUser to work just fine like this:

defmodule ShareStream.Accounts.AccountUser do
  use Ecto.Schema

  schema "account_users" do
    belongs_to :account, Account
    belongs_to :user, User

    timestamps(type: :utc_datetime)
  end
end
dkarter commented 2 weeks ago

@pfac Thank you so much for the detailed response! 💜

The with_user approach worked!

And I guess I can create other custom wrappers (which is good enough for now), I just wish it was part of the library. This is my first time using ExMachina - usually I just roll my own factory module that uses pipes to automatically chain related records.

This code though was not necessary because the preload already populates the keys on the struct:

def with_user(%Account{id: id} = account) when not is_nil(id) do
  # if ID is not nil, assume it is persisted
  user = insert(:user, email: "foo@example.com")
  account_user = insert(:account_user, account: account, user: user)

  account
  |> Repo.preload([:account_users, :users])
-  |> Map.update!(:account_users, &[account_user | &1])
-  |> Map.update!(:users, &[user | &1])
end

For the schema issues - I just omitted the primary key definition because I felt that it was unrelated and I didn't want to distract from the problem - turns out it was more distracting to leave it out 😆, but good eye!

This what the account really looks like:

defmodule ShareStream.Accounts.Account do
  use Ecto.Schema

  @primary_key {:id, UXID, autogenerate: true, prefix: "act", size: :medium}
  schema "accounts" do
    field :name, :string

    has_many :account_users, AccountUser
    has_many :users, through: [:account_users, :user]

    timestamps(type: :utc_datetime)
  end
end

They all have a similar primary key annotation.

Thanks again!