We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
Post 84
Ash Framework 11 - Many-To-Many and Follow/Unfollow
Published on: 2025-11-08
Tags:
elixir, Blog, Side Project, LiveView, Ecto, Html/CSS, Phoenix, Ash, Framework
Chapter 9: Following Your Favorite Artists (207)
Okay so now we want to start to make sure that a person can follow an artist and using many-to-many relationships to do that. I believe this will involve some pubsub as well.
Modelling with a Many-to-Many Relationship
We can link users and users with a many-to-many relationship. A user can have many followed artists and the artist can have many ardent followers.
Creating the ArtistFollower Resource
We will create this with a join resource. This will sit between the two existing resource Tunez.Music.Artist and Tunez.Accounts.User. In this case we want it to follow the idea that a it will go in the Music domain and be in the direction of user -> artist.
mix ash.gen.resource Tunez.Music.ArtistFollower --extend postgres
With that done we can now head to lib/tunez/music/artist_follower.ex
relationships do
belongs_to :artist, Tunez.Music.Artist do
primary_key? true
allow_nil? false
end
belongs_to :follower, Tunez.Accounts.User do
primary_key? true
allow_nil? false
end
end
So for our setup we will not have a id for them as there will be no way a user can follow at artist twice and as such the combination of user and artist will be enough to uniquely identify the relationship. Also we not need to set up what happens when we delete an artist so that we don't try and send out Notifications to users about an artist that doesn't exist.
postgres do
table "artist_followers"
repo Tunez.Repo
references do
reference :artist, on_delete: :delete, index?: true
reference :follower, on_delete: :delete, index?: true
end
end
Nope that we have that setup we can codegen and then migrate the db.
mix ash.codegen create_artist_followers
mix ash.migrate
Using ArtistFollower to Link Artists and Users
We want to be able to have this relationship go both ways and as such make sure that we can load all relevant data when we preload anything in the database. Let's head to lib/tunez/music/artist.ex
relationships do
...
has_many :follower_relationships, Tunez.Music.ArtistFollower
many_to_many :followers, Tunez.Accounts.User do
join_relationship :follower_relationships
destination_attribute_on_join_resource :follower_id
end
end
This will setup the relationship that we need. Now we need to do the same for the user to denote that this will have the same relationship in the other direction. lib/tunez/accounts/user.ex
relationships do
has_many :follower_relationships, Tunez.Music.ArtistFollower do
destination_attribute(:follower_id)
end
many_to_many :followed_artists, Tunez.Music.Artist do
join_relationship(:follower_relationships)
source_attribute_on_join_resource(:follower_id)
end
end
This has set the relationship in the other way. But in order for us to be able to use this and get the info we need we need to add a read action to the artist_follower.ex
use Ash.Resource,
otp_app: :tunez,
domain: Tunez.Music,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
actions do
defaults([:read])
end
policies do
policy action_type(:read) do
authorize_if(always())
end
end
We should now be able to test the resource in an iex session.
iex(8)> Tunez.Music.get_artist_by_id!(«uuid», load: [:follower_relationships])
«two SQL queries to load the data»
#Tunez.Music.Artist<follower_relationships: [], ...>
Who Do You Follow?
With the new relationship we need to make a way for a user to add in an artist to follow. But first we need to be able to show if a user is following that artist. Head to lib/tunez/music/artist.ex
calculations do
...
calculate(
:followed_by_me,
:boolean,
expr(exists(follower_relationships, follower_id == ^actor(:id)))
)
Showing the Current Following Status
Okay so we want to be able to show if a user is following an artist by a filled in or not filled in star and the user should be able to toggle the follow with that same button. First we need to load in the data within the show page. lib/tunez_web/live/artists/show_live.ex
def handle_params(%{"id" => artist_id}, _url, socket) do
artist =
Tunez.Music.get_artist_by_id!(artist_id,
load: [:followed_by_me, albums: [:duration, :tracks]],
actor: socket.assigns.current_user
)
Now we can use the built-in function that will allow for the toggle.
<.h1>
{@artist.name}
<.follow_toggle on={@artist.followed_by_me} />
</.h1>
Following a New Artist
So now we can leverage the ASH framework to create a new function that will take a artist record and the signed in user and create a new record in the ArtistFollower db, ASH will take care of the rest. lib/tunez/music.ex
resource Tunez.Music.ArtistFollower do
define :follow_artist, action: :create, args: [:artist]
end
New we need to create the create action in Tunez.Music.ArtistFollower
"Structs for Action Arguments and Custom Inputs"
Let's take a minute here to add in all the needed code to make it work with our API
mix ash.extend Tunez.Music.ArtistFollower graphql
Now head to lib/tunez/music.ex
mutations do
...
create Tunez.Music.ArtistFollower, :follow_artist, :create
end
Now we want to make it work for a few different ways of doing thing it will involve a few different files so let's get to it.
lib/tunez/music.ex
resource Tunez.Music.ArtistFollower do
define :follow_artist do
action(:create)
args([:artist])
custom_input :artist, :struct do
constraints(instance_of: Tunez.Music.Artist)
transform(to: :artist_id, using: & &1.id)
end
end
end
lib/tunez/music/artist_follower.ex
actions do
defaults([:read])
create :create do
accept([:artist_id])
change(relate_actor(:follower, allow_nil?: false))
end
end
lib/tunez/music/artist_follower.ex
policies do
policy action_type(:read) do
authorize_if(always())
end
policy action_type(:create) do
authorize_if(actor_present())
end
end
Now we can test.
iex(5)> artist = Tunez.Music.get_artist_by_id!(«artist_uuid»)
#Tunez.Music.Artist<...>
iex(6)> user = Tunez.Accounts.get_user_by_email!(«email», authorize?: false)
#Tunez.Accounts.User<...>
iex(7)> Tunez.Music.follow_artist(artist, actor: user)
INSERT INTO "artist_followers" ("artist_id","follower_id") VALUES ($1,$2)
RETURNING "follower_id","artist_id" [«artist_uuid», «user_uuid»]
{:ok, %Tunez.Music.ArtistFollower{...}}
Now let's head to the lib/tunez_web/artists/show_live.ex and add in the functionality to toggle the follow.
def handle_event("follow", _params, socket) do
socket =
case Tunez.Music.follow_artist(socket.assigns.artist,
actor: socket.assigns.current_user
) do
{:ok, _} ->
update(socket, :artist, &%{&1 | followed_by_me: true})
{:error, _} ->
put_flash(socket, :error, "Could not follow artist")
end
{:noreply, socket}
end
Unfollowing an Old Artist
Now we need to setup the way in which we can unfollow the artist that we are following. We need to define an action within our resource lib/tunez/music.ex
define :unfollow_artist do
action :destroy
args([:artist])
require_reference?(false)
custom_input :artist, :struct do
constraints(instance_of: Tunez.Music.Artist)
transform(to: :artist_id, using: & &1.id)
end
end
We need to be careful here as if you were to just use this you would delete every follow record for that artists no just the users. So we need to be sure that when we call this we pass the right information into the DB with a filter that will apply to the artist_follower.ex so that we only delete the right one from that db.
destroy :destroy do
argument :artist_id, :uuid do
allow_nil?(false)
end
change_filter(expr(artist_id == ^arg(:artist_id) && follower_id == ^actor(:id)))
end
Now let's set the policies for this in lib/tunez/music/artist_follower.ex
policy action_type(:destroy) do
authorize_if(actor_present())
end
Now let's test
iex(8)> Tunez.Music.unfollow_artist!(artist, actor: user)
«SQL query to delete ArtistFollowers»
%Ash.BulkResult{
status: :success, errors: nil, records: nil,
notifications: [], error_count: 0
}
Okay we now have that all set
"A Short Detour into Bulk Actions"
A filter will always return a list and that in this case might be too much for an action that might need a long list or even goes into too many DB. There is an other way by using the get_by option. This will help us to only return 1 single record. we can now update the unfollow_artist to use this instead. lib/tunez/music.ex
define :unfollow_artist do
action(:destroy)
args([:artist])
get?(true)
require_reference?(false)
iex(6)> Tunez.Music.unfollow_artist(artist, actor: user)
:ok
"Integrating the Code Interface into the LiveView"
Now we need to add in the unfollow and set the html to use the new event. lib/tunez_web/artists/show_live/ex
def handle_event("unfollow", _params, socket) do
socket =
case Tunez.Music.unfollow_artist(socket.assigns.artist,
actor: socket.assigns.current_user
) do
:ok ->
update(socket, :artist, &%{&1 | followed_by_me: false})
{:error, _} ->
put_flash(socket, :error, "Could not unfollow artist")
end
{:noreply, socket}
end
And then the html
<.h1>
{@artist.name}
<.follow_toggle :if={Tunez.Music.can_follow_artist?(@current_user, @artist)}
on={@artist.followed_by_me}
/>
</.h1>
Spicing Up the Artist Catalog
Now we want to add in more information on the index page to show the status of each artist if they are followed or not.
Showing the Follow Status for Each Artist
Now we can add in more preloaded information within the search_artists resource. lib/tunez/music.ex
define(:search_artists,
action: :search,
args: [:query],
default_options: fn ->
[
load: [
:followed_by_me,
:album_count,
:latest_album_year_released,
:cover_image_url
]
]
end
)
Now the index_live.ex
<.link navigate={~p"/artists/#{@artist.id}"}>
<.follow_icon :if={@artist.followed_by_me} />
<.cover_image image={@artist.cover_image_url} />
</.link>
Showing Follower Counts for Each Artist
Now lets make sure that we have the follower count as well. lib/tunez/music/artist.ex
aggregates do
# calculate :album_count, :integer, expr(count(albums))
count :album_count, :albums do
public?(true)
end
count :follower_count, :follower_relationships
...
end
After we have that we need to be sure that we add in the new Aggregate to the load lib/tunez/music.ex
define(:search_artists,
action: :search,
args: [:query],
default_options: fn ->
[
load: [
:follower_count,
:followed_by_me,
:album_count,
:latest_album_year_released,
:cover_image_url
]
]
end
)
Now the html
<p class="flex justify-between">
<.link
navigate={~p"/artists/#{@artist.id}"}
class="text-lg font-semibold"
data-role="artist-name"
>
{@artist.name}
</.link>
<.follower_count_display count={@artist.follower_count} />
</p>
Sorting Artists by Follower Status and Follower Count
Okay so now we need to add in more sorting options and then add in the sort parameter. lib/tunez_web/artists/index_live.ex
defp sort_options do
[
{"recently updated", "-updated_at"},
{"recently added", "-inserted_at"},
{"name", "name"},
{"number of albums", "-album_count"},
{"latest album release", "-latest_album_year_released"},
{"popularity", "-follower_count"},
{"followed artists first", "-followed_by_me"}
]
end
Now we need to calculate those values and then make them public lib/tunez/music/artist.ex
calculations do
...
calculate(
:followed_by_me,
:boolean,
expr(exists(follower_relationships, follower_id == ^actor(:id))),
public?: true
)
end
aggregates do
# calculate :album_count, :integer, expr(count(albums))
count :album_count, :albums do
public?(true)
end
count :follower_count, :follower_relationships do
public?(true)
end
end
We now have the proper relationships and UI to Follow and Unfollow