Blog

Social Networking with Rails using acts_as_network

Posted: Sep 21, 2007 by Stephen Lombardo Tagged rails

Update 25-APR-2008: This plugin has been updated for Rails 2.0.
Update 06-JUN-2008: This plugin now includes acts_as_union, and we moved the repository to GitHub.

When we started integrating simple social networking features into PingMe we wanted to easily represent a bi-directional relationship between users in the system. When a user signs up for PingMe they can invite another user to join them. Once an invite is accepted, the users become mutual friends, or contacts in PingMe parlance, and can send Pings to each other.

Most importantly, we wanted the relationship to be bidirectional - when Jack is a friend of Jane then Jane should also be a friend of Jack.

Unfortunately we quickly realized that this model was not going to be so easy. The usual way of representing this type of network relationship using ActiveRecord is with an intermediate HABTM join, or with a self-referential has_many :through association. For example one might define a simple person model and then a join table to store the friendship relation:

  create_table :people, :force => true do |t|
    t.column :name, :string
  end

  create_table :friends, {:id => false} do |t|
    t.column :person_id, :integer, :null => false
    t.column :person_id_friend, :integer, :null => false      # target of the relationship
  end

The problem is that this model requires two rows in the intermediate table to make a relationship bi-directional.

  jane = Person.create(:name => 'Jane')
  jack = Person.create(:name => 'Jack')
  
  jane.friends << jack              
  jane.friends.include?(jack)    =>  true   # Jack is Janes friend
  jack.friends.include?(jane)    =>  false  # Jane is NOT Jack's friend

In short, you must explicitly define the reverse relation in order for this to work.

  jack.friends << jane
  jack.friends.include?(jane)    =>  true  # now they're buds

This can be implemented in a fairly DRY way using association callbacks as documented in Rails Recipes, but things start to get ugly when you want to express the relationship through a "proper" join model (like for an Invite) using has_many :through.

  create_table :invites do |t|
    t.column :person_id, :integer, :null => false           # source of the relationship
    t.column :person_id_friend, :integer, :null => false    # target of the relationship
    t.column :code, :string                                 # random invitation code
    t.column :message, :text                                # invitation message
    t.column :is_accepted, :boolean
    t.column :accepted_at, :timestamp                       # when did they accept?
  end
In this case creating a reverse relationship is much more complex and could require the duplication of multiple values, making the data model decidedly non-DRY.

Enter acts_as_network

acts_as_network is a plugin that we developed for PingMe to resolve some of these issues. It drives the social networking features of the site. It's intended to simplify the definition and storage of reciprocal relationships between entities using ActiveRecord by exposing a "network" of 2-way connections.

What makes it special is that it does this in a DRY way using only a single record in an intermediate has_and_belongs_to_many join table or has_many :through join model. There is no redundancy, and you need only one instance of an association or join model to represent both directions of the relationship. Consider this more desirable implementation:

  class Invite < ActiveRecord::Base
    belongs_to :person                    # the source of the invite
    belongs_to :person_target,            # the target of the invite
        :class_name => 'Person', 
        :foreign_key => 'person_id_target'       
  end

  class Person < ActiveRecord::Base
    acts_as_network :friends, :through => :invites, :conditions => ["is_accepted = ?", true]
  end

In this case acts_as_network implicitly defines five new properties on the Person model

  person.invites_out        # has_many invites originating from me to others
  person.invites_in         # has_many invites originating from others to me
  person.friends_out        # has_many friends :through outbound accepted invites from me to others
  person.friends_in         # has_many friends :through inbound accepted invites from others to me
  person.friends            # the union of the two friend sets - all people who I have
                            # invited all the people who have invited me and 

Now...

  # Jane invites Jack to be friends
  invite = Invite.create(:person => jane, :person_target => jack, :message => "let's be friends!")    
  
  jane.friends.include?(jack)    =>  false   # Jack is not yet Jane's friend
  jack.friends.include?(jane)    =>  false   # Jane is not yet Jack's friend either

  invite.is_accepted = true  # Now Jack accepts the invite
  invite.save and jane.reload and jack.reload

  jane.friends.include?(jack)    =>  true   # Jack is Janes friend now
  jack.friends.include?(jane)    =>  true   # Jane is also Jacks friend

So much cleaner!

Most of this magic is actually accomplished with a UnionCollection class that provides useful application-space functionality for emulating set unions across ActiveRecord collections. Once initialized, the UnionCollection itself will act as an array containing all of the records from each of its member sets, but its more interesting feature is that it will intelligently forward ActiveRecord method calls like find, find_all_by_*, etc. to its member sets.

Check it out

Further documentation is available online, and you can easily install acts_as_network as a plugin to try it out:

% script/plugin install git://github.com/sjlombardo/acts_as_network.git
% rake doc:plugins

Please check it out and let us know what you think.

Zetetic is the creator of the super-flexible Tempo Time Tracking system.

  • Saturday, May 10, 2008

    Ismael says:

    I was looking for something like this. Does it support eager loading with the :include option?

    I'll check it out and thanks!

  • Saturday, May 10, 2008

    Hans says:

    You should be careful with [":conditions => "is_accepted = 't'"]. This will for example not work with a MySQL database.

    Instead use [":conditions => "is_accepted = ?", true], which will let Rails autogenerate the correct true value for the current database.

  • Saturday, May 10, 2008

    Stephen says:

    @Hans - you're absolutely right - I've adjusted the example. Thanks!

  • Saturday, May 10, 2008

    Ben says:

    @Hans, thanks very much! Exactly what I was looking for.

  • Saturday, May 10, 2008

    Vicent says:

    Many thanks, this plugin is excelent.

  • Saturday, May 10, 2008

    mmo says:

    I know when creating a social network site how its important to track a users id and make sure they get credit when inviting others.

  • Thursday, June 05, 2008

    millisami says:

    I found the gem I was after for such a long time when I first started my project with ASP. Then later switched to PHP then Java, but still the mystery couldn't be resolved. Now at this stage, with RoR, I'm able to get a good picture of a social-networking site.
    Thanks a lot.

  • Friday, July 11, 2008

    reeze says:

    what a plugin! just I want to find! thx a lot!

  • Monday, July 21, 2008

    Eric says:

    Where can we access the version for Rails 1.2? Thanks for your help.

  • Monday, July 21, 2008

    Eric says:

    Please disregard my comment above. Just migrated to Rails 2.1.0 to try your plugin. It's pretty cool! Thanks for all the hard work.

  • Monday, July 21, 2008

    Eric says:

    Please disregard my comment above. Just migrated to Rails 2.1.0 to try your plugin. It's pretty cool! Thanks for all the hard work.

  • Monday, February 22, 2010

    Sites Internet says:

    Hy,

    I would like to know how to request the date of the invite after I request friend_in.
    The ideal would be to have the date of invite in the user in the array of the friend_in. Because doing so, will only be need 1 mysql request

Add a comment