jlxq0
jlxq0

Reputation: 87

Rails 5, Polymorphic Associations and multiple has_ones

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?

Upvotes: 0

Views: 1760

Answers (1)

Marc Rohloff
Marc Rohloff

Reputation: 1352

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?

Update 1

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.

Option 2

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.

Upvotes: 2

Related Questions