1

I'm trying to allow users in my app to be mutual friends with one another via friend requests and I'm a little confused with the how the relationships work... When a friendship is created by one user and accepted by the other, I would like the friendship to be visible from both users (obviously).

I'd like to achieve an implementation that allows me to do something similar to the following:

user1 friend requests user2
user2 accepts
user1.friends now contains user2
user2.friends now contains user1

Here's what I have so far, but I've read some weird things about nested has_many :through relationships

class User < ActiveRecord::Base
 has_many :friendships
 has_many :friends, :class_name => "User", :through => :friendships
end

class Friendship < ActiveRecord::Base
 has_many :users, :limit => 2
end

Is this a viable implementation? If not, what could I change/improve? I'd like to avoid 2 rows representing one relationship if possible.

Riptyde4
  • 5,134
  • 8
  • 30
  • 57

2 Answers2

3

What you're looking for is a has_and_belongs_to_many relation, but to the same table, kind of like as described in detail by Many-to-many relationship with the same model in rails?. However, since you want the relation to be bi-directional ("my friends are all also friends with me"), you have two options:

  1. Use a single join table, each row of which links two user_ids, but insert two rows for each friendship.

    # no need for extra columns on User
    class User < ActiveRecord::Base
      has_many :friendships
      has_many :friends, through: :friendships
    end
    
    # t.belongs_to :user; t.belongs_to :friend
    class Friendship < ActiveRecord::Base
      belongs_to :user
      belongs_to :friend, class_name: "User"
    end
    
    u1 = User.create!
    u2 = User.create!
    u3 = User.create!
    
    # make users 1 and 2 friends
    u1.friendships.create(friend: u2)
    u2.friendships.create(friend: u1)        
    
    # make users 2 and 3 friends
    u2.friendships.create(friend: u3)
    u3.friendships.create(friend: u2)        
    
    # and now, u1.friends returns [u1],
    # u2.friends returns [u1, u3] and
    # u3.friends returns [u2].
    
  2. Use a single record, but hackery to locate who you're friends with:

    # no need for extra columns on User
    class User < ActiveRecord::Base
      has_many :friendships_as_a, class_name: "Friendship", foreign_key: :user_a_id
      has_many :friendships_as_b, class_name: "Friendship", foreign_key: :user_b_id
    
      def friends
        User.where(id: friendships_as_a.pluck(:user_b_id) +          friendships_as_b.pluck(:user_a_id))
      end
    end
    
    # t.belongs_to :user_a; t.belongs_to :user_b
    class Friendship < ActiveRecord::Base
      belongs_to :user_a, class_name: "User"
      belongs_to :user_b, class_name: "User"
    end
    

That's not the cleanest way to do it, but I think you'll find there isn't really a particularly clean way when set up like that (with a denormalized table). Option 1 is a much safer bet. You could also use a SQL view to hit the middle ground, by generating the mirror entries for each friendship automatically.

Edit: Migration & usage in an API

Per the OP's comment below, to use Option 1 fully, here's what you'd need to do:

rails g migration CreateFriendships

Edit that file to look like:

class CreateFriendships < ActiveRecord::Migration
  create_table :friendships do |t|
    t.belongs_to :user
    t.belongs_to :friend
    t.timestamps
  end
end

Create the Friendship model:

class Friendship < ActiveRecord::Base
  belongs_to :user
  belongs_to :friend, class_name: "User"
end

Then on your User model:

class User < ActiveRecord::Base
  # ...

  has_many :friendships
  has_many :friends, through: :friendships, class_name: 'User'

  # ...
end

And in your API, say a new FriendshipsController:

class FriendshipsController < ApplicationController
  def create
    friend = User.find(params[:friend_id])

    User.transaction do # ensure both steps happen, or neither happen
      Friendship.create!(user: current_user, friend: friend)
      Friendship.create!(user: friend, friend: current_user)
    end
  end
end

Which your route for looks like (in config/routes.rb):

resource :friendships, only: [:create]

And a request to would look like:

POST /friendships?friend_id=42

Then you can refer to current_user.friends whenever you want to find who a user is friends with.

Community
  • 1
  • 1
Robert Nubel
  • 7,104
  • 1
  • 18
  • 30
  • Really helpful but this is for an API and I'm just a little confused what the create action would look like for a controller given that I'm using devise and current_user refers to the current user... I get how to create the friendship but how do I properly associate it with the user and soon-to-be-friend -> also unsure how the migration would look – Riptyde4 Feb 23 '16 at 01:13
  • @Riptyde4: see my Edit to the answer. I added concrete code samples for Option 1, which is my recommended solution. – Robert Nubel Feb 23 '16 at 05:55
  • 1
    You're a saint! Thanks, that clears things up beautifully – Riptyde4 Feb 23 '16 at 06:00
1

You'd use a has_many :through:

#app/models/user.rb
class User < ActiveRecord::Base
  has_many :friendships
  has_many :friends, through: :friendships, -> { where(status: "accepted") }
end

#app/models/friendship.rb
class Friendship < ActiveRecord::Base
  belongs_to :user
  belongs_to :friend, class_name: "User"

  enum status: [:pending, :accepted]
  validates :user, uniqueness: { scope: :friend, message: "You can only add a friend once" }

  def decline
    self.destroy
  end

  def accept
    self.update status: "approved"
  end
end

The above is a self-referential join, allowing the following:

@user   = User.find params[:id]
@friend = User.find params[:friend_id]

@user.friends << @friend

--

This will add a new friendship for the user, with its default status set to pending. The @user.friends association is set so that only accepted friends appear from a call.

Thus, you'll be able to do the following:

#config/routes.rb
resources :users do
  resources :friendships, only: [:index, :destroy, :update], path_names: { destroy: "remove", update: "accept" }
end

#app/controllers/Frienships_controller.rb
class FriendshipsController < ApplicationController
  def index
    @user       = User.find params[:user_id]
    @friendship = @user.friendships
  end

  def update
    @user       = User.find params[:user_id]
    @friendship = @user.friendships.find params[:id]
    @friendship.accept
  end

  def destroy
    @user       = User.find params[:user_id]
    @friendship = @user.friendships.find params[:id]
    @friendship.decline
  end
end

#app/views/friendships/index.html.erb
<%= @friendships.pending.each do |friendship| %>
  <%= link_to "Accept", user_friendships_path(user, friendship), method: :put %>
  <%= link_to "Decline", user_friendships_path(user, friendship), method: :delete %>
<% end %>
Richard Peck
  • 76,116
  • 9
  • 93
  • 147
  • 1
    I'm just a little confused how to use this, what would I do for the create method and migration??? – Riptyde4 Feb 23 '16 at 01:11