Building Self-referential Associations in Elixir/Phoenix

Self-referential association is used to associate a model with itself. It's pretty common to have this kind of association to build relationships in today's social media applications - Twitter, Mastodon, Facebook, etc. However, the association model used on Facebook for friends is somewhat different from the association model used in Twitter-style followers/followings relationships. The first one is symmetric as once you become someone's friend, the other person automatically becomes your friend. However, Twitter style association is asymmetric given if you follow someone, that person does not automatically become your follower. In this post, I will focus on self-referential associations to build Twitter-style followers/followings relationships in Elixir using Ecto and Phoenix.

Terminology and Concepts

I first encountered this problem while learning Ruby on Rails a while back from Michael Hartl's Ruby on Rails Tutorial. While explaining the concepts of building followers/followings relationships, I will extract the terminologies from this tutorial but will model the relationship in Elixir/Phoenix instead of Ruby on Rails.

In our system, there will be User model and Relationship model. I will use these two models to build the mentioned relationships. Corresponding database tables are - users and relationships.

users table will look like this -

relationships table will look like this -

Here -

  • follower_id - foreign key references a follower in users table

  • followed_id - foreign key references a following in users table

We will have two different associations concepts -

  • active_relationships - will be used to eventually build the association for followings

  • passive_relationships - will be used to eventually build the association for followers

I am once again going to refer to and extract a couple of diagrams from the tutorial mentioned at [2] to visually show these two associations -

I will use Ecto's has_many :through association to build our needed followers and followings final associations.

Now that I explained the concepts, it's time to move on straight to implementation.

Implementation

First, create a new Phoenix project using the below command -

mix phx.new fan

Inside the project, add one external dependency [4] to easily generate fake data seed file. Add the below in the dependency section of mix.exs -

{:faker, "~> 0.17", only: [:dev, :test]}

Migrations

Create a couple of migrations to create the necessary users and relationships table -

mix phx.gen.context Accounts User users name:string email:string
mix phx.gen.context Accounts Relationship relationships follower_id:references:users followed_id:references:users

Complete CreateUsers migration looks like this -

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

  def change do
    create table(:users) do
      add :name, :string, null: false
      add :email, :string, null: false

      timestamps()
    end

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

Complete CreateRelationships migration looks like this -

defmodule Fan.Repo.Migrations.CreateRelationships do
  use Ecto.Migration

  def change do
    create table(:relationships) do
      add :follower_id, references(:users, on_delete: :nothing)
      add :followed_id, references(:users, on_delete: :nothing)

      timestamps()
    end

    create index(:relationships, [:follower_id])
    create index(:relationships, [:followed_id])
    create unique_index(:relationships, [:follower_id, :followed_id])
  end
end

The last unique_index is added to prevent creating multiple relationships using the same follower/followed ids.

Run the below command to create and apply the migrations -

mix ecto.migrate

Schemas

The generator used to create migrations created a couple of Ecto schemas too.

User schema looks like this after adding some validations in the changeset -

defmodule Fan.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :email, :string
    field :name, :string

    timestamps()
  end

  @email_regex ~r/\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i

  @doc false
  def changeset(%__MODULE__{} = user \\ %__MODULE__{}, attrs) do
    user
    |> cast(attrs, [:name, :email])
    |> validate_required([:name, :email])
    |> validate_length(:name, min: 3, max: 50)
    |> validate_length(:email, max: 255)
    |> validate_format(:email, @email_regex)
  end
end

Relationship schema looks like this after editing the changeset -

defmodule Fan.Accounts.Relationship do
  use Ecto.Schema
  import Ecto.Changeset

  alias Fan.Accounts.User

  schema "relationships" do
    belongs_to(:follower, User)
    belongs_to(:followed, User)

    timestamps()
  end

  @doc false
  def changeset(%__MODULE__{} = relationship \\ __MODULE__, attrs) do
    relationship
    |> cast(attrs, [:follower_id, :followed_id])
    |> validate_required([:follower_id, :followed_id])
    |> foreign_key_constraint(:follower_id)
    |> foreign_key_constraint(:followed_id)
  end
end

Here, we have a couple of belongs_to association pointing to User schema. I have added foreign_key_constraint on the foreign keys follower_id and followed_id for data integrity so that no relationship can be created without corresponding users table entry.

Followers/Followings Relationships

At this point, we need to add some associations to build our followers and followings relationships. I added the below in User schema -

has_many(:active_relationships, Relationship,
  foreign_key: :follower_id,
  on_delete: :delete_all
)

has_many(:passive_relationships, Relationship,
  foreign_key: :followed_id,
  on_delete: :delete_all
)

has_many(:followings, through: [:active_relationships, :followed])
has_many(:followers, through: [:passive_relationships, :follower])

Already I mentioned the concepts of active and passive relationships. But I added two more has_many :through associations to build the final followings and followers associations.

You can read about how to build has_many :through associations following the link mentioned in [3]. But in short, the active_relationships association refers to active_relationships association from User schema and followed association refers to followed association from Relationship schema to build the followings association. Together this means an actively following relationship, hence the name active_relationships. When you actively follow someone, you passively become that user's follower which is built using passive_relationships and follower associations.

The final User schema should look like this -

defmodule Fan.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  alias Fan.Accounts.Relationship

  schema "users" do
    field :email, :string
    field :name, :string

    has_many(:active_relationships, Relationship,
      foreign_key: :follower_id,
      on_delete: :delete_all
    )

    has_many(:passive_relationships, Relationship,
      foreign_key: :followed_id,
      on_delete: :delete_all
    )

    has_many(:followings, through: [:active_relationships, :followed])
    has_many(:followers, through: [:passive_relationships, :follower])

    timestamps()
  end

  @email_regex ~r/\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i

  @doc false
  def changeset(%__MODULE__{} = user \\ %__MODULE__{}, attrs) do
    user
    |> cast(attrs, [:name, :email])
    |> validate_required([:name, :email])
    |> validate_length(:name, min: 3, max: 50)
    |> validate_length(:email, max: 255)
    |> validate_format(:email, @email_regex)
  end
end

Context Functions

We will need some context functions in generated Accounts context. We need to be able to -

  • follow a user

  • unfollow a user

  • list followers of a user

  • list followings of a user

  • check if a user is following another user

The context module functions are implemented like this (somewhat edited) -

defmodule Fan.Accounts do
  @moduledoc """
  The Accounts context.
  """

  import Ecto.Query, warn: false
  alias Fan.Repo

  alias Fan.Accounts.Use
  alias Fan.Accounts.Relationship

  # functions to manage followers/followings relationships
  def followers(user) do
    list = user |> Repo.preload(:followers)
    list.followers
  end

  def followings(user) do
    list = user |> Repo.preload(:followings)
    list.followings
  end

  def follow(current_user, other_user) do
    %Relationship{}
    |> Relationship.changeset(%{follower_id: current_user.id, followed_id: other_user.id})
    |> Repo.insert()
  end

  def unfollow(current_user, other_user) do
    Repo.get_by!(Relationship, follower_id: current_user.id, followed_id: other_user.id)
    |> Repo.delete()
  end

  def following?(current_user, other_user) do
    query =
      from r in Relationship, where: [follower_id: ^current_user.id, followed_id: ^other_user.id]

    Repo.exists?(query)
  end
end

Note: In a real-world program you would like to paginate the list from followers/1 and followings/1 functions as some users may have way too many followers or followings. To focus on the main topic I omitted pagination from this post.

Seed Data

I wrote a seed.exs file to generate some random users and followers/followings which look like this -

alias Fan.Accounts

for _ <- 1..5,
    do:
      Accounts.create_user(%{
        name: Faker.Name.name(),
        email: Faker.Internet.email()
      })

# followers/followings relationships
users = Accounts.list_users()
user = Enum.at(users, 0)
following = Enum.slice(users, 2, 5)
followers = Enum.slice(users, 3, 5)

for followed <- following, do: Accounts.follow(followed, user)
for follower <- followers, do: Accounts.follow(user, follower)

You can run the seed file using the below command -

mix run priv/repo/seeds.exs

This should create some random users and followers/followings. You should see the users table somewhat like this -

and relationships table somewhat like this -

Testing Context Functions (IEx)

At this stage, we can test our context functions from IEx. Hit up your terminal and launch an IEx session with -

iex -S mix phx.server

At first, we will retrieve the list of followers of user_id 1 -

iex(3)> user = Accounts.get_user!(1)
iex(4)> Accounts.followers(user)
[
  %Fan.Accounts.User{
    __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
    id: 3,
    email: "alexander.west@auer.biz",
    name: "Blanca Bergnaum",
    active_relationships: #Ecto.Association.NotLoaded<association :active_relationships is not loaded>,
    passive_relationships: #Ecto.Association.NotLoaded<association :passive_relationships is not loaded>,
    followings: #Ecto.Association.NotLoaded<association :followings is not loaded>,
    followers: #Ecto.Association.NotLoaded<association :followers is not loaded>,
    inserted_at: ~N[2023-08-12 12:43:52],
    updated_at: ~N[2023-08-12 12:43:52]
  },
  %Fan.Accounts.User{
    __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
    id: 4,
    email: "cassandra_swaniawski@macgyver.org",
    name: "Marjory Lebsack",
    active_relationships: #Ecto.Association.NotLoaded<association :active_relationships is not loaded>,
    passive_relationships: #Ecto.Association.NotLoaded<association :passive_relationships is not loaded>,
    followings: #Ecto.Association.NotLoaded<association :followings is not loaded>,
    followers: #Ecto.Association.NotLoaded<association :followers is not loaded>,
    inserted_at: ~N[2023-08-12 12:43:52],
    updated_at: ~N[2023-08-12 12:43:52]
  },
  %Fan.Accounts.User{
    __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
    id: 5,
    email: "pamela1972@smitham.com",
    name: "Pansy Ankunding",
    active_relationships: #Ecto.Association.NotLoaded<association :active_relationships is not loaded>,
    passive_relationships: #Ecto.Association.NotLoaded<association :passive_relationships is not loaded>,
    followings: #Ecto.Association.NotLoaded<association :followings is not loaded>,
    followers: #Ecto.Association.NotLoaded<association :followers is not loaded>,
    inserted_at: ~N[2023-08-12 12:43:52],
    updated_at: ~N[2023-08-12 12:43:52]
  }
]

We can see user_id 1 has 3 followers.

We can also retrieve the list of users followed by user_id 1 by retrieving the followings list -

iex(5)> Accounts.followings(user)
[
  %Fan.Accounts.User{
    __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
    id: 4,
    email: "cassandra_swaniawski@macgyver.org",
    name: "Marjory Lebsack",
    active_relationships: #Ecto.Association.NotLoaded<association :active_relationships is not loaded>,
    passive_relationships: #Ecto.Association.NotLoaded<association :passive_relationships is not loaded>,
    followings: #Ecto.Association.NotLoaded<association :followings is not loaded>,
    followers: #Ecto.Association.NotLoaded<association :followers is not loaded>,
    inserted_at: ~N[2023-08-12 12:43:52],
    updated_at: ~N[2023-08-12 12:43:52]
  },
  %Fan.Accounts.User{
    __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
    id: 5,
    email: "pamela1972@smitham.com",
    name: "Pansy Ankunding",
    active_relationships: #Ecto.Association.NotLoaded<association :active_relationships is not loaded>,
    passive_relationships: #Ecto.Association.NotLoaded<association :passive_relationships is not loaded>,
    followings: #Ecto.Association.NotLoaded<association :followings is not loaded>,
    followers: #Ecto.Association.NotLoaded<association :followers is not loaded>,
    inserted_at: ~N[2023-08-12 12:43:52],
    updated_at: ~N[2023-08-12 12:43:52]
  }
]

Let's make user_id 1 follow user_id 3 and unfollow user_id 4.

iex(6)> other_user = Accounts.get_user!(3)
iex(7)> Accounts.follow(user, other_user) 
{:ok,
 %Fan.Accounts.Relationship{
   __meta__: #Ecto.Schema.Metadata<:loaded, "relationships">,
   id: 6,
   follower_id: 1,
   follower: #Ecto.Association.NotLoaded<association :follower is not loaded>,
   followed_id: 3,
   followed: #Ecto.Association.NotLoaded<association :followed is not loaded>,
   inserted_at: ~N[2023-08-12 12:56:32],
   updated_at: ~N[2023-08-12 12:56:32]
 }}
iex(8)> other_user = Accounts.get_user!(4)
iex(9)> Accounts.unfollow(user, other_user)
DELETE FROM "relationships" WHERE "id" = $1 [4]
{:ok,
 %Fan.Accounts.Relationship{
   __meta__: #Ecto.Schema.Metadata<:deleted, "relationships">,
   id: 4,
   follower_id: 1,
   follower: #Ecto.Association.NotLoaded<association :follower is not loaded>,
   followed_id: 4,
   followed: #Ecto.Association.NotLoaded<association :followed is not loaded>,
   inserted_at: ~N[2023-08-12 12:43:52],
   updated_at: ~N[2023-08-12 12:43:52]
 }}

We can test following?/1 function to check if a user is following another user or not.

iex(11)> other_user = Accounts.get_user!(3)   
iex(12)> Accounts.following?(user, other_user)
true

iex(13)> other_user = Accounts.get_user!(4)   
iex(14)> Accounts.following?(user, other_user)
false

Conclusion

In this article, I explained the concept of self-referential associations, particularly Twitter-style followers/followings relationships and showed how we can build such relationships using Ecto and Phoenix framework.

The full source code for the example in this article is here - https://github.com/imeraj/phoenix_playground/tree/master/1.7.7/fan

References

  1. Ruby on Rails Tutorial - https://www.railstutorial.org/

  2. Following Users- https://3rd-edition.railstutorial.org/book/following_users

  3. Ecto has_many/has_one :through - https://hexdocs.pm/ecto/Ecto.Schema.html#has_many/3-has_many-has_one-through:~:text=has_many/has_one%20%3Athrough

  4. Faker - https://hex.pm/packages/faker

  5. Source code -https://github.com/imeraj/phoenix_playground/tree/master/1.7.7/fan

For more elaborate and in-depth future technical posts please follow me here or subscribe to my newsletter.

Did you find this article valuable?

Support Meraj Molla by becoming a sponsor. Any amount is appreciated!