I need to state up front that I don’t have a solution to this problem. Hopefully, I can iterate toward a solution- using this interim solution (discussed at the end).
The basic problem that I’ve found is that ActiveResource is a great Rails model to inherit from, except when it isn’t. And it seems that “when it isn’t” basically means “anytime you are using associations.” To be fair, associations don’t always break, it just turns out that my architecture illustrates exactly when they do break.
To lay out the situation, I have a backend server running Rails with three models, User, Service, and Groups. I have a Rails frontend server without a database hitting this (as well as other) backend servers.
class User :members has_many :managed_groups, :class_name => "Group" accepts_nested_attributes_for :managed_groups, :services, :groups end class Group :members belongs_to :manager, :class_name => "User", :foreign_key => :user_id accepts_nested_attributes_for :users end class Service < ActiveRecord::Base belongs_to :user end class Member < ActiveRecord::Base belongs_to :group belongs_to :user end
You can see it’s really basic setup. Users can have many Services, but Service can only have one User. By contrast, users can Manage many groups, and can belong to many Groups, while Groups can have many users who belong to it.
Users -> Services is a basic :has_many relationship and Users -> Groups is a :has_many, :through relationship. There is nothing complicated about this setup at all…
…until you get to an ActiveResource frontend
class User < ActiveResource::Base self.site = "http://localhost:8801" end class Group < ActiveResource::Base self.site = "http://localhost:8801" end class Service < ActiveResource::Base self.site = "http://localhost:8801" end
The frontend server sees all of these models as ActiveResources, which works fine if you’re accessing User.name, but which doesn’t work so well if you’re trying to access User.services because User.services doesn’t exist. ActiveResource simply doesn’t know anything about associations, so you have to work around a big problem.
One easy way to make User.services available to ActiveResource is by including it in the UsersController. Here’s a sample UsersController#Show method on the backend:
def show
@user = User.find(params[:id])
respond_to do |format|
format.xml { render
ml => @user.to_xml(:include => [:managed_groups, :services, :groups]) }
format.json { render :json => @user.to_json(:include => [:managed_groups, :services, :groups]) }
end
end
We just include a list of whatever associated objects we want to include in the returned user package. Easy enough. Starting up the Rails console on the frontend now allows you to access User.services. However, what if you tried the following:
service = Service.create(:name => "Test Service") user = User.first user.services << service
Turns out that we can’t do that. We get an exception that the User model expects a Service class, but was supplied with an IndifferentHash.
With a :has_many/:belongs_to relationship, this is easy to work around:
service = Service.create(:name => "Test Service", :user_id => User.first.id)
Of course, user_id is a “primitive” type, a direct attribute of Server. So we can force it by going to the other side of the relationship and set that attribute and not have a problem.
Working around the :belongs_to issue was easy, working around :has_many, :through is as fun as… well, you get the idea.
The problem is that there’s no backdoor to setting the association. You simply can’t access an array of objects as an association through ActiveResource. Look at what we see when the frontend accesses the backend server with a PUT request at /users/1.xml:
{"user"=>
{"created_at"=>2011-06-23 02:58:15 UTC,
"id"=>1,
"name"=>"John Metta",
"updated_at"=>2011-06-23 02:58:15 UTC,
"managed_groups"=>[],
"services"=>[
{"created_at"=>2011-06-23 02:58:15 UTC,
"id"=>1,
"updated_at"=>2011-06-23 02:58:15 UTC,
"user_id"=>1}
],
"groups"=>[
{"created_at"=>2011-06-23 02:56:37 UTC,
"id"=>1,
"updated_at"=>2011-06-23 02:56:37 UTC,
"user_id"=>nil,
"users"=>[
{"created_at"=>2011-06-23 03:36:28 UTC,
"id"=>6,
"name"=>"Jefferey",
"updated_at"=>2011-06-23 03:36:28 UTC},
{"created_at"=>2011-06-23 02:59:36 UTC,
"id"=>2,
"name"=>"George",
"updated_at"=>2011-06-23 03:05:13 UTC}
]
},
{"created_at"=>2011-06-23 02:56:37 UTC,
"id"=>1,
"name"=>"Site Admin",
"updated_at"=>2011-06-23 02:56:37 UTC,
"user_id"=>nil,
"users"=>[
{"created_at"=>2011-06-23 03:36:28 UTC,
"id"=>6,
"name"=>"Jefferey",
"updated_at"=>2011-06-23 03:36:28 UTC},
{"created_at"=>2011-06-23 02:59:36 UTC,
"id"=>2,
"name"=>"George",
"updated_at"=>2011-06-23 03:05:13 UTC},
{"created_at"=>2011-06-23 02:58:15 UTC,
"id"=>1,
"name"=>"John Metta",
"updated_at"=>2011-06-23 02:58:15 UTC}
]
}
]
},
"id"=>"1"}
Cats talking to dogs, here. They’d both be happy eating a squirrel, sure, but they just speak different languages.
Basically, the backend tries to find the “groups” and “services” properties on the User model. ActiveRecord expects groups to be, well Group objects- but it’s a Hash, not a Group. In order for this to work, those should be named “groups_attributes” and “services_attributes.”
This is basically telling me that ActiveRecord and ActiveResource basically don’t know how to talk to one another. What the hell was the Rails core team thinking?
ActiveResource should be patched to format it’s JSON string to use MODEL_attributes, right? Well, that would break a lot of stuff. ActiveResource is supposed to talk fine to my Scala backend server too, which it wouldn’t.
ActiveRecord should automatically treat MODEL: {ASSOCIATED_MODEL: …}} as something that can be converted to an ActiveRecord, right? I’m not sure of this one- it seems too possible to break things down the line that we didn’t expect.
So, you can do it from either direction without possibly horribly breaking something down the line.
Ahh, apparently the Rails core team was thinking “You know, we shouldn’t do this because we might horribly break something down the line.”
In order to fix this, I’ve added a simple hack to my UsersController on the backend. I say it’s simple because it’s pretty dumb. I say it’s a hack because I think it’s really ugly and inelegant– something I don’t expect from Rails.
Here’s the full UsersController#Update method on my backend server:
def update
@user = User.find(params[:id])
respond_to do |format|
begin
groups = []
params[:user][:groups].each do |g|
groups << Group.find(g[:id])
end
params[:user][:groups] = groups
rescue
end
begin
services = []
params[:user][:services].each do |s|
services < @user.errors, :status => :unprocessable_entity }
format.json { render :json => @user.errors, :status => :unprocessable_entity }
end
end
end
As you can see, we grab that rogue IndifferentHash groups list and, basically, walk through it and find the local groups that correspond to the Group that’s in the list. This works- it allows ActiveResource to barely communicate with ActiveRecord. It’s no longer cats and dogs talking, it’s more like dogs and wolfs.
Yes, I know, wolves bite.
This hack works, but it’s just that- A hack. If something seems this inelegant to me, then I have to question whether it is just that inelegant.
Today a friend mentioned that he’s never used accepts_nested_attributes_for. Never. He has never used nested resources like this because his strong philosophy is “One model, one resource.” I’m going to explore this for a few reasons, not the least of which is that I don’t think I want to pass the groups around inside the user if the user is a member of 3000 groups that we don’t care about. That’s just stupid.
Trying this method would mean that the User model on the frontend would require a groups method that would search the join table based on the user, and an additional add_group method which would go through the Group model to create a new group and somehow add that group to the users list of groups.
There’s a hole in my bucket, dear Liza, dear Liza, a hole in my bucket, dear Liza, a hole…
Really not sure how that would work– at least without some inclusion of my hack above. It seems like there’s just no way to get around this problem elegantly. I hope I’m wrong about that.
Someone please tell me I’m wrong.
I know it’s not all elegant and rails-y, but I agree with the friend you mention at the end. I would just go ahead and add another model class for your manymany join table, and deal with it.
If you don’t like seeing it in your own code, patch ActiveResource to just figure it out for you and work that way by default (or by setting a simple flag).