Search code examples
ruby-on-railsactiverecordpolymorphismpolymorphic-associationshas-one

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?


Solution

  • 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.