Ruby on Rails :: Restful friends with has_many :through
Posted by dnite on November 25, 2006
EDIT (2007-03-07) :: I’ve updated this as a plugin and released it at my new blog (http://blog.dnite.org). I’ll keep this information up here, but everything is a lot cleaner and works a lot better with the new plugin. So go check out my new blog!
EDIT (2007-01-23) :: I’m depreciating this entry/tutorial!! If you have problems getting this working, just be patient, I’m going to be releasing a plugin in the very near future that will work a lot better and be a lot easier to impliment than this! Just give me a few days…I’ve been working on a project of mine and while I learn Rails. One thing that surprised me was that as many tutorials there are on how to create a blog, there are very few about creating a friends system. So I’ll try and go through the system I put together today after many headaches (that I’ll explain later).
We’re going to keep it RESTful here (or as RESTful as we can). I’ve found that the easiest way to approach anything RESTfully is to understand the path first. So let’s start there. These are the path’s we’ll need to accomplish our friends methods.
# List a user's friends (index action, GET method)
/users/1/friends
# Confirm that a user wants said friend. (confirm action, GET method)
/users/1/friends/2;confirm
# Add a friend (add action, POST method)
/users/1/friends/2;add
# Remove a friend (destroy action, DELETE method)
/users/1/friends/2
# Show a friend
/users/1/friends/2 (show action, GET method)
We’ll need a couple of models, a couple of controllers, and a couple of routes. I’m assuming you know how to make a new Rails project, so let’s get started by creating the models. We’ll need a User model and a Friendship model. I’ll keep them simple here.
First, here’s the tables I created.
create_table :users do |t|
t.column :name, :string
t.column :created_at, :datetime
end
create_table :friendships, :id => false do |t|
t.column :user_id, :integer, :null => false
t.column :friend_id, :integer, :null => false
t.column :status, :integer, :null => false, :default => 0
t.column :created_at, :datetime
end
The models look like this.
class User < ActiveRecord::Base
has_many :friendships
has_many :friends, :through => :friendships
end
class Friendship < ActiveRecord::Base
belongs_to :user
belongs_to :friend, :class_name => 'User', :foreign_key => 'friend_id'
end
Now, here’s where I was pulling my hair out. Because, in theory, that association looks ok to me. But if I try and do something like…
User.find(1).friends
I get a nice and very strange SQL error.
Mysql::Error: Column 'friend_id' cannot be null: INSERT INTO friendships (`status`, `user_id`, `created_at`, `friend_id`) VALUES(0, 2, '2006-11-25 01:06:46', NULL)
The strange part isn’t the error. It’s that, one, there is no friend_id (and I still have no idea why) and two, it’s putting what SHOULD be the friend_id into the user_id column in the table. I still have yet to figure out why this happened, so I just went ahead and made my own little work around which works just fine and in all technicality, will probably work better down the road when I have more attributes in the friendships model. But if anyone has any ideas on what could be wrong, I’d love to hear about it.
Anyways.. Back on topic. Now we have the relationships, we need to interact with them. Being RESTful says that we need to do something like /users/1/friends/2 with the correct method. So I created a friends controller that looks like this.
class FriendsController < ApplicationController
def confirm
@friend = User.find_by_id(params[:id])
@current_user = current_user
end
def add
if !friends_already?(params[:user_id], params[:id])
add_friend(params[:user_id], params[:id])
end
redirect_to users_path
end
def destroy
if friends_already?(params[:user_id], params[:id])
remove_friend(params[:user_id], params[:id])
end
redirect_to users_path
end
protected
def friends_already?(user_id, friend_id)
user = User.find(user_id)
friend = User.find(friend_id)
return true if user.friends.include?(friend) && friend.friends.include?(user)
false
end
def add_friend(user_id, friend_id)
Friendship.create({:user_id => user_id, :friend_id => friend_id})
Friendship.create({:user_id => friend_id, :friend_id => user_id})
end
def remove_friend(user_id, friend_id)
Friendship.delete_all "user_id = #{user_id} and friend_id = #{friend_id}"
Friendship.delete_all "user_id = #{friend_id} and friend_id = #{user_id}"
end
end
The confirm action (/users/1/friends/2;confirm) shows a dialog w/ a button that lets the user confirm that he wants to be friends with this person. The button posts to /users/1/friends/2;add. From here it’s pretty self explanitory. It looks really nice when you can call /users/1/friends to list all of someone’s friends. Any extra attributes needed can be added in the add_friend action. Now all you need to do is add the new cool restful routes to config/routes.rb
map.resources :users do |user|
user.resources :friends, :member => { :confirm => :get, :add => :post }
end
That should do ya. Sorry this isn’t a full out tutorial, but you should definately have a good idea on how to impliment a friends system pretty quickly now. Make changes. Do what you want. Let me know if I screwed anything up. I’m still learning this stuff, so what I have here could be completely stupid. Let me know what you think.
November 26, 2006 at 3:39 am
[...] After having a hard time finding information on putting together a nice self-referential association for a friends list, the author has put together a guide to help out.read more | digg story Share and Enjoy:These icons link to social bookmarking sites where readers can share and discover new web pages. [...]
November 28, 2006 at 5:03 pm
I have no idea what this means. Typo?
As for your sql error, your model-defined relations seem a little hard to follow. Maybe you need something like this?
class User
has_many :friends, :through => 'friendships'
end
class Friendship
has_one :user
has_one :friend, :class_name => 'User', :foreign_key => 'friend_id'
end
of course, it is always nice to have some sort of validation and/or before_create/before_validation that makes sure if you are someone’s friend that they are also your friend. Or maybe you could walk a fine semantic line by pretending that one-way friendships are indications of an outstanding friend request (then delete the one-way upon rejecting, allowing the requester to later reiterate his request).
November 28, 2006 at 5:09 pm
ya, sorry. I missed that one. I’ll fix it. WordPress doesn’t like to be nice to me in code and blockquote’s .. so something went wrong when I pasted that. I shall fix it now, thanks for the heads up.
November 28, 2006 at 5:20 pm
Much better. Very sorry about that for anyone else who was confused as well. I completely forgot about the < in the class definition and it killed things. Looks better now. Thanks for letting me know.
November 30, 2006 at 9:22 am
when you say:
“But if I try and do something like… User.find(1).friends”
you mean…
“But if I try and do something like… User.find(1).friends
November 30, 2006 at 9:24 am
‘<’
November 30, 2006 at 9:29 am
ok, I got it , by posting the previous comment I understood the problem,
(needed to use “& l t ;” to write “<”
it’s a wordpress problem, not a RoR one
so , you should also update the line:
User.find(1).friends
with:
User.find(1).friends << another_user
(sorry for the “spam”
November 30, 2006 at 2:04 pm
ya, everyone’s just catching all my mistakes here. indeed, i meant User.find(1).friends << another_user
January 26, 2007 at 5:32 pm
I eagerly await the plugin!