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 inusers
tablefollowed_id
- foreign key references a following inusers
table
We will have two different associations concepts -
active_relationships
- will be used to eventually build the association for followingspassive_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
Ruby on Rails Tutorial - https://www.railstutorial.org/
Following Users- https://3rd-edition.railstutorial.org/book/following_users
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
Faker - https://hex.pm/packages/faker
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.