A better-late-than-never announcement: we released a Rails plugin a while ago that implements a better, DRYer way to roll network relationships using ActiveRecord. It's called, acts_as_network and it now updated to support Rails 2.0.

So why is this such a problem? It may not be immediately apparent, but the short answer is that these types of relationships usually require 2 redundant rows of storage in your database. Take a social network relationship: one record might say that Jack is Jill's friend, but a separate row must be present to say Jill is Jack's friend.

acts_as_network does away with this nonsense, and lets you say implicitly that If Jack is Jill's friend then Jill is Jack's friend. Or, in Ruby

# 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

The syntax is clean, and it stores only one row in your HABTM table. Online Documentation available or install/upgrade the plugin:

% script/plugin source http://actsasnetwork.rubyforge.org/svn/plugins
% script/plugin install acts_as_network  
% rake doc:plugins

Much thanks to Maurycy for submitting patches to AAN!

Note: for a more in depth look at the acts_as_network syntax and usage please check out the original release page.

4 Responses to “Redundant Bidirectional Relationships in Rails Suck”

  1. Tobin Says:
    Hey Stephen, this is great, it's exactly what I was looking for. Do you mind if I ask you a question? I'm having a problem.

    Here's my setup (Rails 2.0.2):
    create_table :connections do |t|
      t.column "inviter_id", :integer
      t.column "invited_id", :integer
      t.column "state", :string
    end
    
    class Machine < ActiveRecord::Base  
      acts_as_network :network, :through => :connections, :conditions => "state = 'accepted'"
    end
    
    class Connection < ActiveRecord::Base
      belongs_to :inviter, :class_name => 'Machine', :foreign_key => 'inviter_id'
      belongs_to :invited, :class_name => 'Machine', :foreign_key => 'invited_id'
    end
    
    I'm getting the following error (in IRB):
    >> m = Machine.find :first
    => #<machine>
    >> m.friends
    ActiveRecord::HasManyThroughSourceAssociationNotFoundError: Could not find the source association(s) :machine in model Connection.  Try 'has_many :friends_in, :through => :connections_in, :source => <name>'.  Is it one of :invited or :inviter?
    	from /opt/local/lib/ruby/gems/1.8/gems/activerecord-2.0.2/lib/active_record/reflection.rb:179:in `check_validity!'
    	from /opt/local/lib/ruby/gems/1.8/gems/activerecord-2.0.2/lib/active_record/associations/has_many_through_association.rb:6:in `initialize'
    	from /opt/local/lib/ruby/gems/1.8/gems/activerecord-2.0.2/lib/active_record/associations.rb:1032:in `new'
    	from /opt/local/lib/ruby/gems/1.8/gems/activerecord-2.0.2/lib/active_record/associations.rb:1032:in `friends_in'
    	from (eval):2:in `friends'
    	from (irb):12
    
    Am I missing something? I thought the friends method was supposed to have been added (along with 4 others).

    (just to clarify, there are no connections records yet, I'm expecting back empty arrays)

    Thanks.
  2. Stephen Says:
    @tobin - The virtual relationship accessor is named based on the first parameter to the acts_as_network call. So, in the example code you posted the Machine class should respond like this:
    >> m = Machine.find :first
    => #<machine>
    >> m.network
    []
    
    If you'd like the relationship to be called friends then you'll need to define as such:
    class Machine < ActiveRecord::Base  
      acts_as_network :friends, :through => :connections, :conditions => "state = 'accepted'"
    end
    
    Hope that helps clarify!
  3. Kim Chirnside Says:
    I have run into problems when using Models with two words in their name. For example: class BigFoo < ActiveRecord::Base acts_as_network :friends, :through => :invites, :conditions => "is_accepted = 't'" end It fails due to your use of 'name.downcase' when creating the has_many relationships 'in' and 'out'. (Line 235-247 in network.rb) It tries to define the source relationship as 'bigfoo_target' and 'bigfoo' when it should be 'big_foo_target' and 'big_foo'. I have remedied this on my copy by changing the name.downcase to name.tableize.singularize This should be safer. I haven't run the unit tests so this needs further investigation. But it would be good if a fix to this did make it into your repo. Other than that, this is a great addition to active record. Thanks!! Kim
  4. Kim Chirnside Says:
    Sorry about the lack of formatting in that previous comment. There are 3 lines of code near the top which you can hopefully extract. I'm not up to speed with blogging and the likes. Kim

Leave a Reply