Home Posts Post Search Tag Search

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)<br>

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.<br>

Modelling with a Many-to-Many Relationship<br>

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.<br>

Creating the ArtistFollower Resource<br>

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 it will go in the Music domain and be in the direction of user -> artist.<br>

mix ash.gen.resource Tunez.Music.ArtistFollower --extend postgres

With that done we can now head to lib/tunez/music/artist_follower.ex<br>

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 an id for them as there will be no way a user can follow an artist twice and as such the combination of user and artist will be enough to uniquely identify the relationship. Also we 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.<br>

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

Now we can codegen and migrate the DB.<br>

mix ash.codegen create_artist_followers
mix ash.migrate    

Using ArtistFollower to Link Artists and Users<br>

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<br>

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

Now do the same for the user to denote the other direction. lib/tunez/accounts/user.ex<br>

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

To use this we need a read action in artist_follower.ex<br>

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

Now we can test the resource in IEx.<br>

iex(8)> Tunez.Music.get_artist_by_id!(«uuid», load: [:follower_relationships])
#Tunez.Music.Artist<follower_relationships: [], ...>

Who Do You Follow?<br>

We need to make a way for a user to add an artist to follow. First we need to show if a user is already following that artist. lib/tunez/music/artist.ex<br>

calculations do
    calculate(
        :followed_by_me,
        :boolean,
        expr(exists(follower_relationships, follower_id == ^actor(:id)))
    )
end

Showing the Current Following Status<br>

Load the data in the show page: lib/tunez_web/live/artists/show_live.ex<br>

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
    )
end

Use a toggle in the HTML:<br>

<.h1>
    {@artist.name}
    <.follow_toggle on={@artist.followed_by_me} />
</.h1>

Following a New Artist<br>

Create a function to create a record in ArtistFollower. lib/tunez/music.ex<br>

resource Tunez.Music.ArtistFollower do
    define :follow_artist, action: :create, args: [:artist]
end

Create the action in Tunez.Music.ArtistFollower<br>

mix ash.extend Tunez.Music.ArtistFollower graphql
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

Test in IEx:<br>

iex(5)> artist = Tunez.Music.get_artist_by_id!(«artist_uuid»)
iex(6)> user = Tunez.Accounts.get_user_by_email!(«email», authorize?: false)
iex(7)> Tunez.Music.follow_artist(artist, actor: user)
{:ok, %Tunez.Music.ArtistFollower{...}}

Add toggle in LiveView: lib/tunez_web/artists/show_live.ex<br>

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<br>

Define action to destroy the follow relationship: lib/tunez/music.ex<br>

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

Be careful to only delete the right record:<br>

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

Set policies in artist_follower.ex<br>

policy action_type(:destroy) do
    authorize_if(actor_present())
end

Test:<br>

iex(8)> Tunez.Music.unfollow_artist!(artist, actor: user)
:ok

A Short Detour into Bulk Actions<br>

To avoid removing multiple rows unnecessarily, use the get_by option:<br>

lib/tunez/music.ex
define :unfollow_artist do
    action(:destroy)
    args([:artist])
    get?(true)
    require_reference?(false)
end

Integrate into LiveView: lib/tunez_web/artists/show_live.ex<br>

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

HTML toggle:<br>

<.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<br>

Add more information on the index page to show follow status.

Showing the Follow Status for Each Artist<br>

Preload additional information in the search_artists resource: lib/tunez/music.ex<br>

define(:search_artists,
    action: :search,
    args: [:query],
    default_options: fn ->
        [
            load: [
                :followed_by_me,
                :album_count,
                :latest_album_year_released,
                :cover_image_url
            ]
        ]
    end
)

Index HTML: lib/tunez_web/artists/index_live.ex<br>

<.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<br>

Add aggregates in lib/tunez/music/artist.ex<br>

aggregates do
    count :album_count, :albums do
        public?(true)
    end

    count :follower_count, :follower_relationships do
        public?(true)
    end
end

Load the new aggregate: lib/tunez/music.ex<br>

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
)

HTML:<br>

<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<br>

Add sorting options: lib/tunez_web/artists/index_live.ex<br>

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

Make calculations and aggregates public: lib/tunez/music/artist.ex<br>

calculations do
    calculate(
        :followed_by_me,
        :boolean,
        expr(exists(follower_relationships, follower_id == ^actor(:id))),
        public?: true
    )
end
aggregates do
    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 artists.<br>