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?
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.
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
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!
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With