Trouble with getting M2M relationship working

Hi,

I am new to both Elixir and Phoenix and to learn I have been building a simple App similar to what I have in production in Python. I am stuck on trying to create an Account record when a User is created (on the basis that every user must have an account, but a user could belong to many accounts, and an account could be shared with multiple users). I am using POW for auth and that was working fine until I tried to extend what was happening to automatically create an account when creating a user. This is what I have:

defmodule TestApp.Accounts.Account do
  @moduledoc """
  Main high level account that users belong to.
  """
  require Logger;
  use Ecto.Schema
  import Ecto.Changeset
  alias TestApp.Accounts.{User, UserAccount}

  schema "accounts" do
    field :name, :string
    field :suspended, :boolean, default: false
    field :owner, :id
    field :company_name, :string
    field :address, :string
    field :city, :string
    field :county, :string
    field :country, :string
    field :zip_or_postcode, :string
    many_to_many :users, User, join_through: UserAccount 

    timestamps()
  end

  @doc false
  def changeset(account, attrs) do
    account
    |> Map.put(:name, attrs[:email])
    |> cast(attrs, [:name])
  end
end

defmodule TestApp.Accounts.User do
  use Ecto.Schema
  use Pow.Ecto.Schema
  import Ecto.Changeset
  alias TestApp.Accounts.{Account, UserAccount}

  schema "users" do
    pow_user_fields()
    field :first_name, :string
    field :last_name, :string
    field :type, :string
    field :bio, :string
    field :photo, :string
    field :provider, :string
    field :token, :string
    field :suspended, :boolean
    field :locked, :boolean
    field :units, :string
    field :currency, :string
    field :company_name, :string
    field :address, :string
    field :city, :string
    field :county, :string
    field :country, :string
    field :zip_or_postcode, :string
    field :terms_signed, :boolean
    many_to_many :accounts, Account, join_through: UserAccount 

    timestamps()
  end

  @doc false
  def changeset(user_or_changeset, attrs) do
    user_or_changeset
    |> cast(attrs, [:first_name, :last_name, :email, :provider, :token, :password_hash, :bio, :photo])
    |> pow_changeset(attrs)
    |> validate_required([:email, :password])
    |> unique_constraint(:email)
    |> maybe_add_account()
  end

  defp maybe_add_account(changeset) do
    case Ecto.get_meta(changeset.data, :state) do
      :built -> add_account(changeset)
      :loaded -> changeset
    end
  end

  defp add_account(changeset) do
    cast_assoc(changeset, :accounts, required: true )
  end

end

defmodule TestApp.Accounts.UserAccount do
  @moduledoc """
  Join throught table for Users and Accounts. So that Users always belong to at least one account.
  """
  use Ecto.Schema
  import Ecto.Changeset
  alias TestApp.Accounts.{Account, User}

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

    timestamps()
  end

  @doc false
  def changeset(user_account, attrs) do
    user_account
    |> cast(attrs, [:user_id, :account_id])
    |> validate_required([:user_id, :account_id])
  end
end

And Migrations:

defmodule TestApp.Repo.Migrations.CreateUsers do
  use Ecto.Migration

  def change do
    create table(:users) do
      add :email, :string, null: false
      add :password_hash, :string
      add :first_name, :string
      add :last_name, :string
      add :type, :string
      add :bio, :text
      add :photo, :string
      add :provider, :string
      add :token, :string
      add :suspended, :boolean, default: false
      add :locked, :boolean, default: false
      add :units, :string
      add :currency, :string
      add :company_name, :string
      add :address, :string
      add :city, :string
      add :county, :string
      add :country, :string
      add :zip_or_postcode, :string
      add :terms_signed, :boolean, default: false

      timestamps()
    end

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

defmodule TestApp.Repo.Migrations.CreateAccounts do
  use Ecto.Migration

  def change do
    create table(:accounts) do
      add :name, :string
      add :suspended, :boolean, default: false, null: false
      add :company_name, :string
      add :address, :string
      add :city, :string
      add :county, :string
      add :country, :string
      add :zip_or_postcode, :string
      add :owner, references(:users, on_delete: :nothing)

      timestamps()
    end

    create index(:accounts, [:owner])
  end
end

defmodule TestApp.Repo.Migrations.CreateUserAccounts do
  use Ecto.Migration

  def change do
    create table(:user_accounts) do
      add :user_id, references(:users, on_delete: :nothing)
      add :account_id, references(:accounts, on_delete: :nothing)

      timestamps()
    end

    create index(:user_accounts, [:user_id])
    create index(:user_accounts, [:account_id])
    create unique_index(:user_accounts, [:user_id, :account_id])
  end
end

I have an phx generated context for users, but nothing else, and what I think might be relevant is the create_user is just:

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

Because I think the Changeset is what actually is done via ecto to the database? Perhaps I am wrong in that assumption.

Any help would be really appreciated.

Thanks! Tim

1 Like

This is outside Pow, but I would guess it has to do with how you cast the associations. It’s not clear to me what error you receive?

One question why do you have country field 2 times in your migration and schema. Did you wish that or is it a typo?

Yes it’s outside of Pow, I don’t actually seem to get an error apart from the front end when I am registering a user, just gives a generic failure message.

It’s Country and County.

OK, maybe use state they are very similar otherwise.

What does the payload you pass to create_user look like? cast_assoc(:accounts) is going to expect something shaped like:

%{"accounts" => [%{"email" => "foo@example.com"}]}

It looks like create_user is not being called, perhaps because of Pow? If I look at the attrs being passed to my changeset then that is:

%{
  "confirm_password" => "123password",
  "email" => "test@test.com",
  "first_name" => "first",
  "last_name" => "last",
  "password" => "123password"
}

I have been thinking of trying with put_assoc instead of using cast_assoc.

I got this to work in the end, I think the valid thing wi with the changeset:

  @doc false
  def changeset(user_or_changeset, attrs) do
    user_or_changeset
    |> cast(attrs, [:first_name, :last_name, :email, :provider, :token, :password_hash, :bio, :photo])
    |> pow_user_id_field_changeset(attrs)
    |> pow_current_password_changeset(attrs)
    |> new_password_changeset(attrs, @pow_config)
    |> validate_required([:email])
    |> unique_constraint(:email)
    |> maybe_add_account(attrs)
  end

  defp maybe_add_account(changeset, attrs) do
    case Ecto.get_meta(changeset.data, :state) do
      :built -> add_account(changeset, attrs)
      :loaded -> changeset
    end
  end

  defp add_account(changeset, attrs) do
    # https://hexdocs.pm/ecto/constraints-and-upserts.html
    put_assoc(changeset, :accounts, [get_or_insert_account(attrs)])
  end

  defp get_or_insert_account(attrs) do
    if email = attrs["email"] do
      Repo.get_by(Account, name: email) ||
        Repo.insert!(%Account{name: attrs["email"]}, on_conflict: :nothing)
    end
  end

Oh, and in case anyone is reading this in the future, I created a custom controller for Pow and then I could see the error message that was being generated the changeset.