Working with Transactions

Hello! I'm working on converting the following Elixir/Ecto function into an Ash action:
def create_team(attrs, user_id) do
Ecto.Multi.new()
|> Ecto.Multi.insert(:team, Team.changeset(%Team{}, attrs))
|> Ecto.Multi.insert(:member, fn %{team: team} ->
TeamMember.changeset(%TeamMember{}, %{
team_id: team.id,
user_id: user_id,
role: "admin"
})
end)
|> Repo.transaction()
end
def create_team(attrs, user_id) do
Ecto.Multi.new()
|> Ecto.Multi.insert(:team, Team.changeset(%Team{}, attrs))
|> Ecto.Multi.insert(:member, fn %{team: team} ->
TeamMember.changeset(%TeamMember{}, %{
team_id: team.id,
user_id: user_id,
role: "admin"
})
end)
|> Repo.transaction()
end
This function performs two operations within a transaction: Creates a new Team with the provided attributes. Creates a new TeamMember associated with the newly created team and assigns the "admin" role to the specified user. Here's my current Team resource definition in Ash:
defmodule Noted.Workspace.Team do
use Ash.Resource,
otp_app: :noted,
domain: Noted.Workspace,
authorizers: [Ash.Policy.Authorizer],
data_layer: AshPostgres.DataLayer

postgres do
table "teams"
repo Noted.Repo
end

actions do
defaults [:read]

create :create_team do
# 1. Create new Team
# 2. Create a new TeamMember, relate it to the actor and newly created team, and assign it an admin role (this must be done in a transaction)
end
end

attributes do
uuid_v7_primary_key :id

attribute :name, :string do
allow_nil? false
public? true
constraints min_length: 2, max_length: 50
end

create_timestamp :created_at
update_timestamp :updated_at
end

relationships do
has_many :invitations, Noted.Workspace.Invitation

many_to_many :users, Noted.Accounts.User do
through Noted.Workspace.TeamMember
end
end
end
defmodule Noted.Workspace.Team do
use Ash.Resource,
otp_app: :noted,
domain: Noted.Workspace,
authorizers: [Ash.Policy.Authorizer],
data_layer: AshPostgres.DataLayer

postgres do
table "teams"
repo Noted.Repo
end

actions do
defaults [:read]

create :create_team do
# 1. Create new Team
# 2. Create a new TeamMember, relate it to the actor and newly created team, and assign it an admin role (this must be done in a transaction)
end
end

attributes do
uuid_v7_primary_key :id

attribute :name, :string do
allow_nil? false
public? true
constraints min_length: 2, max_length: 50
end

create_timestamp :created_at
update_timestamp :updated_at
end

relationships do
has_many :invitations, Noted.Workspace.Invitation

many_to_many :users, Noted.Accounts.User do
through Noted.Workspace.TeamMember
end
end
end
I'm looking for guidance on how to implement the :create_team action in Ash to replicate the behavior of the original Ecto.Multi function. Specifically, I want to ensure that both the team creation and the associated team member creation occur within the same transaction.
Solution:
```elixir change fn changeset, context -> Ash.Changeset.after_action(changeset, fn changeset, team -> team_member_attrs = %{ # actor is in the context...
Jump to solution
7 Replies
ZachDaniel
ZachDaniel•3w ago
So a guide to answer this is up next on my list, but in the meantime, what you are looking for in Ash is lifecycle hooks
create :create_team do
change fn changeset, _ ->
Ash.Changeset.after_action(changeset, fn changeset, team ->
# create the team member
{:ok, team}
end)
end
end
create :create_team do
change fn changeset, _ ->
Ash.Changeset.after_action(changeset, fn changeset, team ->
# create the team member
{:ok, team}
end)
end
end
after_action hooks happen in the same transaction 🙂
Joan Gavelán
Joan GavelánOP•3w ago
Great, this is the way to go. One question though, how to access the actor data?
change fn changeset, _ ->
Ash.Changeset.after_action(changeset, fn changeset, team ->
team_member_attrs = %{
# How to get the actor id?
user_id: "",
team_id: team.id,
role: "admin"
}

IO.inspect(team_member_attrs, label: "Team member")

{:ok, team}
end)
end
change fn changeset, _ ->
Ash.Changeset.after_action(changeset, fn changeset, team ->
team_member_attrs = %{
# How to get the actor id?
user_id: "",
team_id: team.id,
role: "admin"
}

IO.inspect(team_member_attrs, label: "Team member")

{:ok, team}
end)
end
Solution
ZachDaniel
ZachDaniel•3w ago
change fn changeset, context ->
Ash.Changeset.after_action(changeset, fn changeset, team ->
team_member_attrs = %{
# actor is in the context
user_id: context.actor.id,
team_id: team.id,
role: "admin"
}

IO.inspect(team_member_attrs, label: "Team member")

{:ok, team}
end)
end
change fn changeset, context ->
Ash.Changeset.after_action(changeset, fn changeset, team ->
team_member_attrs = %{
# actor is in the context
user_id: context.actor.id,
team_id: team.id,
role: "admin"
}

IO.inspect(team_member_attrs, label: "Team member")

{:ok, team}
end)
end
Joan Gavelán
Joan GavelánOP•3w ago
This is how I got it working:
create :create_team do
accept [:name]

change fn changeset, context ->
Ash.Changeset.after_action(changeset, fn changeset, team ->
team_member_attrs = %{
user_id: context.actor.id,
team_id: team.id,
role: "admin"
}

Noted.Workspace.add_team_member!(team_member_attrs, tenant: team.id)

{:ok, team}
end)
end
end
create :create_team do
accept [:name]

change fn changeset, context ->
Ash.Changeset.after_action(changeset, fn changeset, team ->
team_member_attrs = %{
user_id: context.actor.id,
team_id: team.id,
role: "admin"
}

Noted.Workspace.add_team_member!(team_member_attrs, tenant: team.id)

{:ok, team}
end)
end
end
I think this can be optimized a little bit. Is there a way to use the tenant id to relate it to the resource being created, something similar to relate_actor/2? This is my TeamMember resource
defmodule Noted.Workspace.TeamMember do
use Ash.Resource,
otp_app: :noted,
domain: Noted.Workspace,
authorizers: [Ash.Policy.Authorizer],
data_layer: AshPostgres.DataLayer

postgres do
table "team_members"
repo Noted.Repo

references do
reference :user, on_delete: :delete
reference :team, on_delete: :delete
end
end

actions do
create :add_team_member do
accept [:user_id, :team_id, :role]
end

read :list_user_teams do
multitenancy :bypass

filter expr(user_id == ^actor(:id))
prepare build(load: :team)
end
end

policies do
policy action_type(:read) do
authorize_if relates_to_actor_via(:user)
end

policy action_type(:create) do
authorize_if always()
end
end

multitenancy do
strategy :attribute
attribute :team_id
end

relationships do
belongs_to :user, Noted.Accounts.User do
primary_key? true
public? true
allow_nil? false
end

belongs_to :team, Noted.Workspace.Team do
primary_key? true
public? true
allow_nil? false
end

belongs_to :team_role, Noted.Workspace.Role do
public? true
allow_nil? false
source_attribute :role
destination_attribute :name
attribute_type :string
end
end
end
defmodule Noted.Workspace.TeamMember do
use Ash.Resource,
otp_app: :noted,
domain: Noted.Workspace,
authorizers: [Ash.Policy.Authorizer],
data_layer: AshPostgres.DataLayer

postgres do
table "team_members"
repo Noted.Repo

references do
reference :user, on_delete: :delete
reference :team, on_delete: :delete
end
end

actions do
create :add_team_member do
accept [:user_id, :team_id, :role]
end

read :list_user_teams do
multitenancy :bypass

filter expr(user_id == ^actor(:id))
prepare build(load: :team)
end
end

policies do
policy action_type(:read) do
authorize_if relates_to_actor_via(:user)
end

policy action_type(:create) do
authorize_if always()
end
end

multitenancy do
strategy :attribute
attribute :team_id
end

relationships do
belongs_to :user, Noted.Accounts.User do
primary_key? true
public? true
allow_nil? false
end

belongs_to :team, Noted.Workspace.Team do
primary_key? true
public? true
allow_nil? false
end

belongs_to :team_role, Noted.Workspace.Role do
public? true
allow_nil? false
source_attribute :role
destination_attribute :name
attribute_type :string
end
end
end
In the same way, is there something similar to relates_to_actor_via/2 but for tenants that I can use in my policies? Currently I just have authorize_if always() as you can see
mcoll
mcoll•3w ago
What is the difference between
change after_action(...)
change after_action(...)
and
change fn changeset, _ ->
Ash.Changeset.after_action(changeset, record ->
...
end
end
change fn changeset, _ ->
Ash.Changeset.after_action(changeset, record ->
...
end
end
ZachDaniel
ZachDaniel•3w ago
The first one is a shortcut for the second I usually show the longer one so people understand what's happening
Joan Gavelán
Joan GavelánOP•2w ago
Turns out I don't need to specify the "team_id" team member attribute when I'm already passing in a tenant. It takes the tenant's value and assigns it automatically
create :create_team do
description "Creates a new Team and assigns the actor as an admin by creating a new TeamMember record in a single transaction"

accept [:name]

change after_action(fn _changeset, team, context ->
team_member_attrs = %{
user_id: context.actor.id,
role: "admin"
}

Noted.Workspace.add_team_member!(team_member_attrs,
tenant: team,
authorize?: false
)

{:ok, team}
end)
end
create :create_team do
description "Creates a new Team and assigns the actor as an admin by creating a new TeamMember record in a single transaction"

accept [:name]

change after_action(fn _changeset, team, context ->
team_member_attrs = %{
user_id: context.actor.id,
role: "admin"
}

Noted.Workspace.add_team_member!(team_member_attrs,
tenant: team,
authorize?: false
)

{:ok, team}
end)
end
I am using this small protocol implementation in my tenant Team resource by the way. In case anyone gets confused as to why I am passing the whole tenant this time.
defimpl Ash.ToTenant do
def to_tenant(%{id: id}, _resource), do: id
end
defimpl Ash.ToTenant do
def to_tenant(%{id: id}, _resource), do: id
end
I am using the attribute strategy so I just need to convert the passed tenant into an id. More info about this protocol: https://hexdocs.pm/ash/multitenancy.html#possible-values-for-tenant

Did you find this page helpful?