How to model a mutual friendship in Rails
Asked Answered
P

3

6

I know this question has been asked before on Stack Overflow, but the answers aren't doing it for me in ways I can explain. My general approach was inspired by this tutorial.

What I'm trying to do, is create a really simple model for friending users that creates an equivalent friendship on both ends with a single record.

At the db level, I just have a 'friendships' table that just has a user_id, a friend_id, and an is_pending boolean column.

In user.rb I've defined the relationship as:

has_many :friendships
has_many :friends, through: :friendships

In friendship.rb, I've defined the relationship as:

belongs_to :user
belongs_to :friend, :class_name => 'User'

If I add a friendship, I can access as follows:

> a = User.first
> b = User.last
> Friendship.new(a.id, b.id)
> a.friends
=> #<User b>

That's perfect, but what I want is to also be able to go in the other direction like so:

> b.friends

Unfortunately, with the relationship defined as it is, I get an empty collection. The SQL that runs shows that it's searching for user_id = b.id. How do I specify that it should also search for friend_id = b.id?

Penetration answered 15/5, 2016 at 22:21 Comment(2)
What did you finally decide on? Curious minds want to know!Detonate
@MichaelGaskill I am thinking I will just remove the relationship and define a friends method on user.rb that will run a simple query. I'm planning on wrapping up the feature tonight, and if I don't have a change of heart, I'll paste my final code and a writeup.Penetration
W
6

This is also achievable with a single has_many :through association and some query fiddling:

# app/models/friendship.rb

class Friendship < ApplicationRecord
  belongs_to :user
  belongs_to :friend, class_name: 'User'
end

 

# app/models/user.rb

class User < ApplicationRecord
  has_many :friendships,
    ->(user) { FriendshipsQuery.both_ways(user_id: user.id) },
    inverse_of: :user,
    dependent: :destroy

  has_many :friends,
    ->(user) { UsersQuery.friends(user_id: user.id, scope: true) },
    through: :friendships
end

 

# app/queries/friendships_query.rb

module FriendshipsQuery
  extend self

  def both_ways(user_id:)
    relation.unscope(where: :user_id)
      .where(user_id: user_id)
      .or(relation.where(friend_id: user_id))
  end

  private

  def relation
    @relation ||= Friendship.all
  end
end

 

# app/queries/users_query.rb

module UsersQuery
  extend self

  def friends(user_id:, scope: false)
    query = relation.joins(sql(scope: scope)).where.not(id: user_id)

    query.where(friendships: { user_id: user_id })
      .or(query.where(friendships: { friend_id: user_id }))
  end

  private

  def relation
    @relation ||= User.all
  end

  def sql(scope: false)
    if scope
      <<~SQL
        OR users.id = friendships.user_id
      SQL
    else
      <<~SQL
        INNER JOIN friendships
          ON users.id = friendships.friend_id
          OR users.id = friendships.user_id
      SQL
    end
  end
end

It may not be the simplest of them all but it's certainly the DRYest. It does not use any callbacks, additional records and associations, and keeps the association methods intact, including implicit association creation:

user.friends << new_friend

via gist.github.com

Warrington answered 20/5, 2020 at 2:32 Comment(1)
Just want to say, this is a thing a beauty! Kudos!Throe
S
3

Maybe this:

friendship.rb
belongs_to :friend_one, :foreign_key => :user_id
belongs_to :friend_two, :foreign_key => :friendship_id

and

user.rb

has_many :friendship_ones, :class_name => 'Friendship', :foreign_key => :friendship_id
has_many :friend_ones, through: :friendship_ones
has_many :friendship_twos, :class_name => 'Friendship', :foreign_key => :user_id
has_many :friend_twos, through: :friendship_twos


def friends
  friend_ones + friend_twos
end

You get two queries to find the friends, but it is a simple data model and you you do just call @user.friends to find the instances.

It would be amenable to eager loading, if you load the two friend_ones and friend_twos associations.

Sikes answered 15/5, 2016 at 22:34 Comment(3)
Hmmm. At that point, wouldn't it make more sense to just do has_many :friendships, then define friends with a little custom ActiveRecord query?Penetration
Could be. That's another approach that would be worth writing up as an option.Sikes
Nice overall solution. And you could have that friends method simply perform the join query to get the results that you want.Detonate
D
2

This article shows how to set up reciprocal relationships: Bi-directional relationships in Rails

It shows how to use after_create and after_destroy to insert additional relationships that model the reciprocal relationship. In that way, you'd have double the records in your join table, but you'd have the flexibility of using a.friends and b.friends and seeing that both include each other correctly.

Making it work with your model:

class Person < ActiveRecord::Base
  has_many :friendships, :dependent => :destroy
  has_many :friends, :through => :friendships, :source => :person
end

class Friendship < ActiveRecord::Base
  belongs_to :person, :foreign_key => :friend_id
  after_create do |p|
    if !Friendship.find(:first, :conditions => { :friend_id => p.person_id })
      Friendship.create!(:person_id => p.friend_id, :friend_id => p.person_id)
    end
  end
  after_update do |p|
    reciprocal = Friendship.find(:first, :conditions => { :friend_id => p.person_id })
    reciprocal.is_pending = self.is_pending unless reciprocal.nil?
  end
  after_destroy do |p|
    reciprocal = Friendship.find(:first, :conditions => { :friend_id => p.person_id })
    reciprocal.destroy unless reciprocal.nil?
  end
end

I've used this approach successfully on a few projects, and the convenience is fantastic!

Detonate answered 15/5, 2016 at 22:33 Comment(2)
How sure are you that this requires two records? Doing it that way crossed my mind but I prefer to do it with a single record and thought there'd be a fairly straightforward way to do it. If not, I'll just double the records.Penetration
You'd also use an after_update to maintain the value of is_pending, of course.Sikes

© 2022 - 2024 — McMap. All rights reserved.