Ash.Changeset.manage_relationship many_to_many with extra fields

I’m trying to have a many to many relationship through an association table with an extra column.

I want to let users create a MyApp.Leagues.League resource and on create they should also be automatically added to the MyApp.Leagues.User association table with role: :admin.

I want the admin to be the actor performing the create action.

I’m using the default Accounts.User from ash_authentication.

I’ve tried a variety combinations of on_lookup and on_match. From the docs, I think I should be using on_lookup: :relate because the Accounts.User already exists, I’m creating a new League and a new Leagues.User so I don’t need to worry about missing records, I really just want to match an existing user.

However, it seems nothing is being inserted.

Here’s the code.

defmodule MyApp.Leagues.User do
  use Ash.Resource,
    otp_app: :my_app,
    domain: MyApp.Leagues,
    data_layer: AshPostgres.DataLayer

  postgres do
    table "league_users"
    repo MyApp.Repo
  end

  actions do
    defaults [:read, :create, :update]
  end

  attributes do
    # simple enum with :admin | :user values
    attribute :role, MyApp.Leagues.UserRole do
      allow_nil? false
      public? true
      default :user
    end
  end

  relationships do
    belongs_to :league, MyApp.Leagues.League, primary_key?: true, allow_nil?: false
    belongs_to :user, MyApp.Accounts.User, primary_key?: true, allow_nil?: false
  end
end

defmodule MyApp.Leagues.League do
  use Ash.Resource,
    otp_app: :my_app,
    domain: MyApp.Leagues,
    data_layer: AshPostgres.DataLayer

  postgres do
    table "leagues"
    repo MyApp.Repo
  end

  actions do
    create :create do
      accept [:name]

      change fn changeset, ctx ->
        Ash.Changeset.manage_relationship(
          changeset,
          :users,
          [%{id: ctx.actor.id, role: :admin}],
          on_lookup: :relate,
          join_keys: [:role]
        )
      end
    end
  end

  attributes do
    uuid_primary_key :id
    create_timestamp :created_at
    update_timestamp :updated_at

    attribute :name, :string do
      allow_nil? false
      public? true
    end
  end

  relationships do
    many_to_many :users, MyApp.Accounts.User do
      through MyApp.Leagues.User
      source_attribute_on_join_resource :league_id
      destination_attribute_on_join_resource :user_id
    end
  end
end

These two posts in the forum are very similar, but the suggestions didn’t fully solve my issue.

From there, I’ve added join_keys which was missing

join_keys: [:role]

I’m a bit confused because this seems like it should be fairly straightforward.

EDIT: if it can be helpful in understanding more, I have logged the changeset and context in the change

Ash.Changeset<
  domain: MyApp.Leagues,
  action_type: :create,
  action: :create,
  attributes: %{name: "My league"},
  relationships: %{},
  errors: [],
  data: %MyApp.Leagues.League{
    users: #Ash.NotLoaded<:relationship, field: :users>,
    users_join_assoc: #Ash.NotLoaded<:relationship, field: :users_join_assoc>,
    __meta__: #Ecto.Schema.Metadata<:built, "leagues">,
    id: nil,
    created_at: nil,
    updated_at: nil,
    name: nil
  },
  valid?: true
>
%Ash.Resource.Change.Context{
  actor: %MyApp.Accounts.User{
    __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
    id: "22c6a0e0-ac34-478a-a4e2-7717c7db14a0",
    email: #Ash.CiString<"email1748014983857613000@example.com">,
    confirmed_at: nil
  },
  tenant: nil,
  authorize?: true,
  tracer: nil,
  bulk?: false
}

EDIT2: If I remove the role: :admin this works, but obviously the inserted League.User has the default role: :user
EDIT3: if I pass the ctx.actor instead of a new map with id and role, the League.User is inserted correctly, but with a default role: :user

Your original example ought to work, but the issue is that everything goes through actions, even manage relationship. So on creating this link, it would use the primary create action on MyApp.Leagues.User which doesn’t accept a :role, so it is ignored. You can modify what the defaults accept without having to define custom actions like so:

defaults [:read, :update, create: [:role]]
1 Like

Hey Zach, thanks for the prompt reply.
I see what you mean, but even like that it wouldn’t work.
You pushed me in the right direction though, so ultimately I added a new custom create action:

  # MyApp.Leagues.User
actions do
  create :add_admin do
    accept [:league_id, :user_id]

    change set_attribute(:role, :admin)
  end
end

# MyApp.Leagues.League
actions do
  create :create do
    accept [:name]

    change fn changeset, ctx ->
      Ash.Changeset.manage_relationship(
        changeset,
        :users,
        [ctx.actor],
        on_lookup: {:relate, :add_admin}
      )
    end
  end
end

This is possibly even better as it handles the role in the user action without exposing the implementation details

1 Like