I have a rails app as follows: A location model which stores some geo-stuff (a location basically), a post model and a user model. A post model can have a location. A user model can have a location as home location and another one as remote location:
class Location < ApplicationRecord
belongs_to :locationable, polymorphic: true
end
class Post < ApplicationRecord
has_one :location, as: :locationable
accepts_nested_attributes_for :location
end
class User < ApplicationRecord
has_one :homelocation, as: :locationable, class_name: 'Location'
has_one :remotelocation, as: :locationable, class_name: 'Location'
accepts_nested_attributes_for :homelocation, :remotelocation
end
The post and location stuff works great. If I delete one of the ´has_one´ lines from the user model and rename homelocation to location, everything works great too. If I want a user to have two different locations though, I get an 'Unpermitted parameters: homelocation, remotelocation' error when trying to save changes.
My users_controller has a
def user_params
params.require(:user).permit(:admin, :name, :motto, homelocation_attributes: [:id, :address], remotelocation_attributes: [:id, :address])
end
just as the posts_controller has a
def post_params
params.require(:post).permit(:title, :content, location_attributes: [:id, :address])
end
My forms look like this:
.form-group.string.required.user_homelocation_address
label.control-label.string.required for="user_homelocation_address"
abbr title="required"
| Home Location
input#user_homelocation_address.form-control.string.required name="user[homelocation][address]" type="text"
.form-group.string.required.user_remotelocation_address
label.control-label.string.required for="user_remotelocation_address"
abbr title="required"
| Remote Location
input#user_remotelocation_address.form-control.string.required name="user[remotelocation][address]" type="text"
So why does this work for one 'has_one', but not for two?
The issue is really that the Location
does not know if it is the User's homelocation
or remotelocation
. The solution to this is to make the User
belong to the Location
class Location < ApplicationRecord
end
class Post < ApplicationRecord
belongs_to :location
accepts_nested_attributes_for :location
end
class User < ApplicationRecord
belongs_to :homelocation, class_name: 'Location'
belongs_to :remotelocation, class_name: 'Location'
accepts_nested_attributes_for :homelocation, :remotelocation
end
And, obviously, change the tables to match.
There is no easy way to navigate back from a Location
to it's owner. Is this a requirement?
Something I didn't say initially, and obviously with a vague understanding of the requirements, is that I consider polymorphic belongs_to
to be one of those 'considered evil' topics. It is almost always a bad code smell, it means you can't implement foreign keys which I consider an essential practice and there are other ways to solve the problems it is trying to solve. My instinct would be to create 2 models and 2 tables, UserLocation
and PostLocation
.
As I said initially the problem remains, how do you know if a Location is a home location or a remote location, or in other words what does location.locationable = some_user
set? There is no way for Rails to know and this is really what you need to solve.
Given the model above, there are ways to navigate from the Location
to it's owner but to make it perform decently I would suggest that you add a type
field to the Location
table so that you know if it is a post
, home
or remote
location. You could then write:
class Location < ApplicationRecord
def owner
case type
when 'post' Post.where(location_id: self).first
when 'home' User.where(homelocation_id: self).first
when 'remote' User.where(remotelocation_id: self).first
end
# or #
def owner
case type
when 'post' Post.where(location_id: self).first
else User.where('homelocation_id = ? OR remotelocation_id = ?', self, self).first
end
end
You could in theory do the same thing using STI wih a Location
class and UserLocation
and PostLocation
subclasses.
Having thought about it, I might implement this would be:
class Location < ApplicationRecord
belongs_to :locationable, polymorphic: true
## Again add a `type` field ##
end
class Post < ApplicationRecord
has_one :location, as: :locationable
accepts_nested_attributes_for :location
end
class User < ApplicationRecord
has_many :locations, as: :locationable, class_name: 'Location'
has_one :homelocation, class_name: 'Location', foreign_key: 'locationable_id', -> { where(type: 'home') }
has_one :remotelocation, class_name: 'Location', foreign_key: 'locationable_id', -> { where(type: 'remote') }
accepts_nested_attributes_for :homelocation, :remotelocation
end
Though my thoughts about belongs_to polymorphic
still stand :)
In all cases, you might also need to write code to create the homelocation
and remotelocation
instances rather than using accepts_nested_attributes
. Also these options would not perform well if you are retrieving many records and trying to solve the n+1
problem.