Check out my new company

MeetSpace Logo MeetSpace: Video conferencing for distributed teams

RESTful Many to Many Relationships in Rails

by on

GET    /people => List people
POST /people => Create person
PUT /people => Replace the collection
DELETE /people => Delete the collection

GET /people/:id => List attribute of a person
PUT /people/:id => update the attributes of a person
POST /people/:id => create a new collection under a person
DELETE /people/:id => Delete a person

In rails, we use

index:   GET    /people     => List people
create: POST /people => Create person
show: GET /people/:id => List attribute of a person
update: PUT /people/:id => update the attributes of a person
destroy: DELETE /people/:id => Delete a person

Now we may also have:

index:   GET    /groups     => List groups
create: POST /groups => Create group
show: GET /groups/:id => List attribute of a group
update: PUT /groups/:id => update the attributes of a group
destroy: DELETE /groups/:id => Delete a group

Consider a relationship of Many To Many between users and groups.

How do I express the following?

"Add user X to group Y"

First thought may be:

POST /groups/Y/users?user_id=X

But technically, this means "Create a user whose attributes are {user_id => X} under group Y". This is wrong in two ways, the first is that we don't want to create a user inside a group. The second is that the user attributes are wrong, it's {id => X}.

The correct request would be:

POST /groups_users?user_id=X&group_id=Y

This means "Create a new GroupUser linking node whose attributes are {user_id => X, group_id => Y}".


OK now how do I express the following?

"Put user X into a new group, whose attributes are {name => 'My Group'}"

This is *two* requests:

POST /groups?name="My Group" => returns ID Z
POST /groups_users?user_id=X&group_id=Z

However, we can actually combine the requests like this:

POST /users/X/groups?name="My Group"

In a situation where a Group Belongs To a User, this would create a group under the user.

You would get in trouble if Group Belongs To a User and Group Has and Belongs To Many Users. However, in that case, you should be using another name for one of the assets. For example, Group could belong to a Creator, and a User would have a created_groups association. So the routes would actually be different:

POST /users/X/created_groups?name="My Group"

Which would mean create a group, where User X is the creator of that group.


So, how do we handle this with Rails routing and controllers?

resources :groups do
resources :users, :controller => 'GroupsUser', :only => [:index, :create, :destroy]
end
resources :users do
resources :groups, :controller => 'UserGroups', :only => [:index, :create, :destroy]
end

Now, for user and group resources, we would use a traditional controller. For the GroupsUser and UserGroups controller, it would be a bit different.

UserGroups controller:

index: return all the groups this user is in
create: create a new group, and add this user to that group
destroy: remove this group from the user's list of groups

Note we don't have:

show: this is redundant with groups/show.
update: this would update the attributes on a membership. If it's just a join there are no attributes, however this may be useful if the join has attributes (for example, role)


The most interesting action here is create:

@user = User.find(params[:id])
@group = Group.new(params[:group])
Group.transaction do
if @group.save
if GroupUser.create(params[:group_user].merge{:user => @user, :group => @group})
# success
else
# Could not add user to group
end
else
# Could not create group
end
end

Note that this controller is getting a bit dangerous because there are three logic paths. Generally, controllers will only have two logic paths: success and failure.

Note also that we are merging the params[:group_user] when creating it. This is because we may want to have attributes on the GroupUser. This would all have to be in the form for posting to this action.


Technically, we are creating a group under a user. While this makes perfect sense in a belongs_to relationship, it is a little mind-bending in a many-to-many situation where the relationship is reciprocal. So, I'll end with a question. Do you think that this "double action" is a violation of REST?

Comments

pjb3
First of all, let me say that I am honored to be the first commenter on ngauthier.com :)

Second, what I would do is push the logic of creating a group down into the controller. The nested attributes feature of Rails gives you this automatically. Assuming your User model looks like this:

class User < ActiveRecord::Base
has_many :group_users
has_many :groups, :through => :group_users

accepts_nested_attributes_for :groups
end

Then the controller goes back to having just two paths, because user.save will take care of creating the group before creating the group_user. So when you do:

PUT /users/X/?group_attributes[][name]="My Group"

this would happen:

user.update_attributes :groups_attributes => [{:name => "My Group"}]
# => INSERT INTO "groups" ("name") VALUES ('My Group')
# => INSERT INTO "group_memberships" ("group_id", "user_id") VALUES (1, 1)
Nick Gauthier
You also get the honor of the first reply!

While that is convenient, it is not RESTful. The route you described is designed to modify the user resource, not any groups resources.

Also, you're going to go through "error hell" if a bunch of those groups and user attributes are invalid.

Doing nested attributes through a controller is trying to squeeze two entire controllers into one. For the sake of simplicity, testing, and maintainability, I'd avoid it.

-Nick
blog comments powered by Disqus