Friendship associations with rails

Let’s say we’re building an event scheduling application. We want our users to be able to form friendships with one another, create events, and invite friends to those events. Implementing these functions poses some interesting challenges when using Rails and Active Record. In this post I’ll work through those challenges.

It is worth noting up front that we don’t need to rebuild the wheel here. There are plenty of gems that will allow you to easily implement friendship relationships, including:

In this post, though, I’ll be building the friendship functionality from scratch. Also, for the sake of space, I won’t be building out the friend request or invitation functions in this post.

Users and Events

Our users can create events, so we’ll have at least two tables in our database: users and events. Each user can have any number of events and every event belongs to a user. So, our User and Event models might look like this:

class User < ApplicationRecord
  has_many :events
end

class Event < ApplicationRecord
  belongs_to :user
end

Recall, however, that we’re going to invite other users to these events. In addition to a user having many events, an event might have many users. This is a many_to_many relationship, so we need a join table and a corresponding model, Attendance, and we need to update our other models:

class Attendance < ApplicationRecord
  belongs_to :user
  belongs_to :event
end

class User < ApplicationRecord
  has_many :events
  has_many :attendances
end

class Event < ApplicationRecord
  belongs_to :user
  has_many :attendances
end

This will work, but it doesn’t make the associations particularly clear. It would be nice if we could find all of the events a user is attending by calling something like @user.attendee_events, instead of @user.attendances.each {|attendance| attendance.event}.

Normally, we would handle something like this with has_many :through, but in this case we’ll have a conflict: @user.events will be ambiguous between events the user created and events the user is attending. It will default to whichever is stated later in the model. So, if the model looks like this…

class User < ApplicationRecord
  has_many :events
  has_many :attendances
  has_many :events, through: :attendances
end

…then @user.events will pull up the events the user is attending, but if the model looks like this…

class User < ApplicationRecord
  has_many :attendances
  has_many :events, through: :attendances
  has_many :events
end

…then @user.events will pull up the events the user created. Either way, we’ve lack the ability to easily call up one of the associations.

Enter association aliasing. By giving the association between users and events an alias, we can distinguish between the attending relationship and the owning relationship. Here’s what it might look like:

class User < ApplicationRecord
  has_many :owner_events, class_name: 'Event', foreign_key: 'owner_id'
  has_many :attendances
  has_many :events, through: :attendances
end

class Event < ApplicationRecord
  belongs_to :owner, class_name: 'User'
  has_many :attendances
  has_many :users, through: :attendances
end

Now the event belongs to an owner, which Active Record will look for among the users, and a user can have many owner_events, which Active Record will look for among events where the foreign key, ‘owner_id’, matches the user’s id. Before this will work, however, we need to update our event migration:

class CreateEvents < ActiveRecord::Migration[7.0]
  def change
    create_table :events do |t|
      t.string :title
      t.references :owner, foreign_key: { to_table: :users }

      t.timestamps
    end
  end
end

Now the events table will include an owner_id attribute but Active Record will recognize that it refers to a user_id.

Once we have all this in place we can pull up the events a user is attending with @user.events and the events they have created with @user.owner_events. Similarly, we can pull up the user who owns an event with @event.owner and the users who are attending an event with @event.users.

Let’s take this a step further. Instead of accessing the events a user is attending with @user.events, let’s set it up so we can call them with @user.attending_events and instead of accessing the attending users for an event with @event.users, let’s set it up so we can call them with @event.attendees. This will make the usage for our application a bit clearer.

First, let’s change our Attendance model:

class Attendance < ApplicationRecord
  belongs_to :attendee, class_name: 'User'
  belongs_to :attendee_event, class_name: 'Event'
end

We’ve given the user an alias of attendee and the event an alias of attendee event. Next, let’s make the necessary changes to the attendances migration:

class CreateAttendances < ActiveRecord::Migration[7.0]
  def change
    create_table :attendances do |t|
      t.references :attendee, foreign_key: { to_table: :users }
      t.references :attendee_event, foreign_key: { to_table: :events }

      t.timestamps
    end
  end
end

Now the attendance table will include columns for attendee_id and attendee_event_id but Active Record will know that they refer to users and events. Next, let’s make the necessary changes to the User model:

class User < ApplicationRecord
  has_many :owner_events, class_name: 'Event', foreign_key: 'owner_id'
  has_many :attendances, foreign_key: 'attendee_id'
  has_many :attendee_events, through: :attendances
end

No changes need to be made to the users migration for this. Next, let’s update the Event model:

class Event < ApplicationRecord
  belongs_to :onwer, class_name: 'User'
  has_many :attendances, foreign_key: 'attendee_event_id'
  has_many :attendees, through: :attendances, class_name: 'User'
end

As with the users migration, no changes need to be made to the events migration.

Great! Now our usage is pretty clear:

  • Get a user’s owned events with @user.owner_events
  • Get the events a user is attending with @user.attending_events
  • Get the owner of an event with @event.owner
  • Get the attendees of an event with @event.attendees

Friendships

We’ll see some of the same moves we used above in developing the friending functionality of the application, but there are some additional challenges. First and foremost, since users are friends with other users, we will be creating an association between the User model and itself.

First, we’ll create a Friendship model and migration with rails generate model Friendship.

The friendship will belong to two users, the one who initiates the friendship and the one who accepts the friendship. We’ll call these the friender and the friendee, respectively. Here’s what our model will look like:

class Friendship < ApplicationRecord
  belongs_to :friender, class_name: 'User'
  belongs_to :friendee, class_name: 'User'
end

The friendships migration will look like this:

class CreateFriendships < ActiveRecord::Migration[7.0]
  def change
    create_table :friendships do |t|
      t.references :friender, foreign_key: { to_table: :users }
      t.references :friendee, foreign_key: { to_table: :users }

      t.timestamps
    end
  end
end

Finally, we need to update our User model:

class User < ApplicationRecord
  has_many :owner_events, class_name: 'Event', foreign_key: 'owner_id'
  has_many :attendances, foreign_key: 'attendee_id'
  has_many :attendee_events, through: :attendances
  has_many :friender_friendships, class_name: 'Friendship', foreign_key: 'friender_id'
  has_many :friendee_friendships, class_name: 'Friendship', foreign_key: 'friendee_id'
  has_many :frienders, class_name: 'User', through: :friendee_friendships
  has_many :friendees, class_name: 'User', through: :friender_friendships
end

So far, so good. If we want to know who has accepted a user’s friend requests we can call @user.friendees and if we want to know whose requests a user has accepted we can call @user.frienders. But what if we just want to know who a user’s friends are, irrespective of who friended whom? For that we can add a simple instance method to the User model:

def friends()
  friendees = self.friendees
  frienders = self.frienders
  friends = friendees + frienders
end

Now if we call @user.friends we will get all the friendees and frienders of the user.

Dependencies

We’re almost done; we just need to make sure things get cleaned up nicely when users, events, or friendships are deleted.

First, when an event is deleted the associated attendances should be deleted, too. This is easily accomplished in the Event model:

class Event < ApplicationRecord
  belongs_to :onwer, class_name: 'User'
  has_many :attendances, foreign_key: 'attendee_event_id', dependent: :destroy
  has_many :attendees, through: :attendances, class_name: 'User'
end

Next, when a user is deleted their friendships should be deleted, the events they created should be deleted, and their attendances should be deleted:

class User < ApplicationRecord
  has_many :owner_events, class_name: 'Event', foreign_key: 'owner_id', dependent: :destroy
  has_many :attendances, foreign_key: 'attendee_id', dependent: :destroy
  has_many :attendee_events, through: :attendances
  has_many :friender_friendships, class_name: 'Friendship', foreign_key: 'friender_id', dependent: :destroy
  has_many :friendee_friendships, class_name: 'Friendship', foreign_key: 'friendee_id', dependent: :destroy
  has_many :frienders, class_name: 'User', through: :friendee_friendships
  has_many :friendees, class_name: 'User', through: :friender_friendships
end

Finally, when a user unfriends another user, i.e., when a friendship is deleted, the relevant attendances should be deleted, too. The relevant attendances are any attendance where the attendee is one friend and the attendee_event is owned by the other friend.

This presents a problem. As it stands, the Friendship model has no associations with attendances. Let’s correct that. Since a user can only attend events that are created by their friends, every attendance can belong to a friendship. So, we can change the Attendance model and migration as follows:

class Attendance < ApplicationRecord
  belongs_to :attendee, class_name: 'User'
  belongs_to :attendee_event, class_name: 'Event'
  belongs_to :friendship
end

class CreateAttendances < ActiveRecord::Migration[7.0]
  def change
    create_table :attendances do |t|
      t.references :attendee, foreign_key: { to_table: :users }
      t.references :attendee_event, foreign_key: { to_table: :events }
      t.references :friendship

      t.timestamps
    end
  end
end

Finally, we change the Friendship model as follows:

class Friendship < ApplicationRecord
  belongs_to :friender, class_name: 'User'
  belongs_to :friendee, class_name: 'User'
  has_many :attendances, dependent: :destroy
end

Excellent! Now any attendances associated with a friendship will be deleted when the friendship is deleted. No ex-friends at your events!

For more fun with associations, check our Ruby on Rails: Association Basics.

, ,

Leave a comment