For the first time, I attempted creating a button that a user could click to follow or unfollow another user.
Database and Self-Join
First of all, the database is set up as a many-to-many relationship. But, it is also self-referential, otherwise known as a self-join. This means that the data in the database uses another table (in this case “Friendship”) to reference a table with itself (in this case “User”). More on this later…
Here is the schema and a diagram to demonstrate these database tables:
// db/schema.rb
create_table "friendships", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "follower_id"
t.integer "followee_id"
end
create_table "users", force: :cascade do |t|
t.string "username"
t.string "password_digest"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
After reading this and this, in addition to the Rails guide, I got an understanding of Self-Join. In the case I used it, based on this, the database needs to be set up just like a many-to-many relationship. It needs a separate table to act as a go-between and store the action’s information. The only difference would be that instead of this separate table in between two other tables, it would have one other table referencing itself.
User >- Friendship -< User
(the User here is the same table - just the connection would look like this)
instead of something like this…
User >- Like -< Post
Another way to look at it is that the Friendship table holds the information pertaining to the “follow” action in this case, and that info contains the id of the user that initiated the action as a foreign key as well as the id of the user the action was initiated upon also as a foreign key. It has two user ids, one in the follower column and the other in the followee column.
The Friendship database table would look something like this:
follower_id | followee_id |
---|---|
1 | 2 |
2 | 1 |
NOTE: For clarification, the first row has the user with the id of 1 following the user with the id of 2, and the second row has the user with the id of 1 being followed by the user with the id of 2. Also, the ids of the users are the primary keys from the User table. On a side note, this table would also have another column for the primary key of the relationship or row in the Friendship table.
Rails Backend
On the back-end of things, there are primarily two things, the models and the controllers concerning this Friendship table as well as the User table.
The most important part of this whole thing lies in the models, especially the User model. Both of these models set up the connections for the tables in the database. The Friendship model essentially allows the follower
and followee
to be present and that they come from the User model.
// app/models/friendship.rb
class Friendship < ApplicationRecord
belongs_to :follower, class_name: "User"
belongs_to :followee, class_name: "User"
end
The User model is a bit more complicated. It needs the send the follower
and followee
to the Friendship model. In order to do that it needs to define them.
// app/models/user.rb
class User < ApplicationRecord
has_many :followed_users, foreign_key: :follower_id, class_name: "Friendship"
has_many :followees, through: :followed_users
has_many :following_users, foreign_key: :followee_id, class_name: "Friendship"
has_many :followers, through: :following_users
end
Here the followers
and followees
are the same as the follower
and followee
from the Friendship model. They are defined using has_many, through:
. The key that it is through here is from another has_many
which is defined using foreign_key:
. This foreign key is the key used to define the column in the Friendship table from the database, which were follower_id
and followee_id
. These are from the Friendship table and are the foreign keys that are defined clearly in the model here with the has_many
statement. The has_many, through:
statements are simply to allow access to these foreign keys in the Friendship model under new labels for clarity.
The controllers define the actions of the models. Here the Friendship controller is the more important one. It defines the adding and deleting of data to the Friendship table, or in other words the creation and deletion of new rows in the table.
// app/controllers/friendships_controller.rb
class FriendshipsController < ApplicationController
def create
friendship = Friendship.create!(friendship_params)
render json: friendship, status: :created
end
def destroy
friendship = Friendship.find_by!({follower_id: session[:user_id], followee_id: params[:id]})
friendship.destroy
head :no_content
end
private
def friendship_params
params.permit(:id, :followee_id, :follower_id)
end
end
In the methods defined here, the Friendship class must be used to define the action. If the action is defined using the User class (ex. User.followers.create!) the action of this method will most likely trigger the creation or deletion of new users, rather than the creation or deletion of a relationship between them (aka. a following or unfollowing).
NOTE: Using
User.followers
orUser.followees
will work in the console will the shovel method. Look at this for more information or examples on this.
Also, the parameters or params used are the foreign keys directly from the table rather than the new labels from the model. In addition, the params used for the delete method use the user id saved in the session since this action can only be performed when there is a user logged in anyways, and the other params are from the fetch request route that has the :id
of the user being followed. The delete here grabs both of these in an object as the follower_id
and followee_id
respectively. This is so the find_by
will target the whole row in the Friendship table that has the exact same information.
The creation method is similar, except that this object is defined in the body of the fetch request instead.
React Frontend
// client/src/components/FollowBtn.js
function FollowBtn({currentUser, user, onError, onFollow, isFollowing}) {
function handleFollow(e) {
const following = currentUser ? {followee_id: user.id, follower_id: currentUser.id} : null
isFollowing ? (
fetch(`/friendships/${user.id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
}
})
.then((r) => {
onFollow(false)
})
) : (
fetch("/friendships", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(following)
})
.then((r) => {
if (r.ok) {
r.json().then((data) => {
onFollow(true)
})
} else {
r.json().then((err) => onError(err.errors))
}
})
)
}
return(
<>
<button onClick={handleFollow} className="button">{isFollowing ? "Unfollow" : "Follow"}</button>
</>
)
}
export default FollowBtn;
This is a lot, but this is the component for the Follow button that includes the function on the front-end that handles the clicking of the button.
It takes the currentUser
and user variables from its parent. The currentUser
is the variable fetched from the user saved in the session, in other words, whoever is logged in on the browser. The user
is from the data that the button is set on, which also makes it whoever the follow is attached to or the one to be followed. If there is a current user or a user logged in, the following
variable is an object containing the id of the user as the followee_id
and the id of the current user as the follower_id
.
The following
variable is the object for the body of the fetch request. The isFollowing
variable is a boolean that checks to see if the current user is already following this user. If they are already following them, then the click on the button will go to the DELETE fetch request, otherwise it will go to the POST fetch request. The DELETE fetch sends the request to the /:id/unfollow
route I have defined, but this requires the id of the user that this button is attached to. The POST fetch does not require the id because it is in the body of the request.
The onFollow
sends the data of these actions to the parent, which controls the isFollowing
variable as well as whether the button displays “FOLLOW” or “UNFOLLOW”. The onError
similarly sends data to the parent, but it only sends the /errors/ if there are any. This was simply for display convenience, because it looked better in the parent rather than inside the button itself 😅.
For more references:
These are some articles I found on this topic, or at least a part of or similar topics. Mostly revolving around self-join (since this was uncharted territory for me).
- https://medium.com/@TheDickWard/rails-relationships-a-user-by-any-other-name-c6c9f0adc972
- https://medium.com/full-taxx/how-to-add-likes-to-posts-in-rails-e81430101bc2
- https://medium.com/@klee.mcintosh/setting-up-a-self-join-with-rails-activerecord-9137062fac8b
- https://www.kartikey.dev/2020/09/29/many-to-many-self-joins-in-rails.html
- https://stackoverflow.com/questions/25493368/many-to-many-self-join-in-rails
- https://medium.com/@asuthamm/self-join-in-rails-8e3fc99c0634
- https://flatironschool.com/blog/self-referential-associations-aka-self-joins/
- https://tmtarpinian.com/self-joins-in-rails/
- https://betterprogramming.pub/building-self-joins-and-triple-joins-in-ruby-on-rails-455701bf3fa7
Top comments (0)