Rails Polymorphic Association with multiple associations on the same model

Ruby on-RailsPolymorphic Associations

Ruby on-Rails Problem Overview


My question is essentially the same as this one: https://stackoverflow.com/questions/1168047/polymorphic-association-with-multiple-associations-on-the-same-model

However, the proposed/accepted solution does not work, as illustrated by a commenter later.

I have a Photo class that is used all over my app. A post can have a single photo. However, I want to re-use the polymorphic relationship to add a secondary photo.

Before:

class Photo 
   belongs_to :attachable, :polymorphic => true
end

class Post
   has_one :photo, :as => :attachable, :dependent => :destroy
end

Desired:

class Photo 
   belongs_to :attachable, :polymorphic => true
end

class Post
   has_one :photo,           :as => :attachable, :dependent => :destroy
   has_one :secondary_photo, :as => :attachable, :dependent => :destroy
end

However, this fails as it cannot find the class "SecondaryPhoto". Based on what I could tell from that other thread, I'd want to do:

   has_one :secondary_photo, :as => :attachable, :class_name => "Photo", :dependent => :destroy

Except calling Post#secondary_photo simply returns the same photo that is attached via the Photo association, e.g. Post#photo === Post#secondary_photo. Looking at the SQL, it does WHERE type = "Photo" instead of, say, "SecondaryPhoto" as I'd like...

Thoughts? Thanks!

Ruby on-Rails Solutions


Solution 1 - Ruby on-Rails

I have done that in my project.

The trick is that photos need a column that will be used in has_one condition to distinguish between primary and secondary photos. Pay attention to what happens in :conditions here.

has_one :photo, :as => 'attachable', 
        :conditions => {:photo_type => 'primary_photo'}, :dependent => :destroy

has_one :secondary_photo, :class_name => 'Photo', :as => 'attachable',
        :conditions => {:photo_type => 'secondary_photo'}, :dependent => :destroy

The beauty of this approach is that when you create photos using @post.build_photo, the photo_type will automatically be pre-populated with corresponding type, like 'primary_photo'. ActiveRecord is smart enough to do that.

Solution 2 - Ruby on-Rails

In Rails 5 you have to define attr_accessor for :attachable_id and specify for relation :class_name and :foreign_key options only. You will get ...AND attachable_type = 'SecondaryPhoto' if as: :attachable used

class Post
  attr_accessor :attachable_id
  has_one :photo, :as => :attachable, :dependent => :destroy
  has_one :secondary_photo, -> { where attachable_type: 'SecondaryPhoto' }, class_name: "Photo", dependent: :destroy, foreign_key: :attachable_id

Rails 4.2+

class Photo
   belongs_to :attachable, :polymorphic => true
end

class Post
   has_one :photo, :as => :attachable, :dependent => :destroy
   has_one :secondary_photo, -> { where attachable_type: "SecondaryPhoto"},
     class_name: Photo, foreign_key: :attachable_id,
     foreign_type: :attachable_type, dependent: :destroy
end

You need to provide foreign_key according ....able'ness or Rails will ask for post_id column in photo table. Attachable_type column will fills with Rails magic as SecondaryPhoto

Solution 3 - Ruby on-Rails

None of the previous answers helped me solve this problem, so I'll put this here incase anyone else runs into this. Using Rails 4.2 +.

Create the migration (assuming you have an Addresses table already):

class AddPolymorphicColumnsToAddress < ActiveRecord::Migration
  def change
    add_column :addresses, :addressable_type, :string, index: true
    add_column :addresses, :addressable_id, :integer, index: true
    add_column :addresses, :addressable_scope, :string, index: true
  end
end

Setup your polymorphic association:

class Address < ActiveRecord::Base
  belongs_to :addressable, polymorphic: true
end

Setup the class where the association will be called from:

class Order < ActiveRecord::Base
  has_one :bill_address, -> { where(addressable_scope: :bill_address) }, as: :addressable,  class_name: "Address", dependent: :destroy
  accepts_nested_attributes_for :bill_address, allow_destroy: true

  has_one :ship_address, -> { where(addressable_scope: :ship_address) }, as: :addressable, class_name: "Address", dependent: :destroy
  accepts_nested_attributes_for :ship_address, allow_destroy: true
end

The trick is that you have to call the build method on the Order instance or the scope column won't be populated.

So this does NOT work:

address = {attr1: "value"... etc...}
order = Order.new(bill_address: address)
order.save!

However, this DOES WORK.

address = {attr1: "value"... etc...}
order = Order.new
order.build_bill_address(address)
order.save!

Hope that helps someone else.

Solution 4 - Ruby on-Rails

Something like following worked for querying, but assigning from User to address didn't work

User Class

has_many :addresses, as: :address_holder
has_many :delivery_addresses, -> { where :address_holder_type => "UserDelivery" },
       class_name: "Address", foreign_key: "address_holder_id"

Address Class

belongs_to :address_holder, polymorphic: true

Solution 5 - Ruby on-Rails

Future reference for people checking this post

This can be achieved using the following code...

Rails 3:

has_one :banner_image, conditions: { attachable_type: 'ThemeBannerAttachment' }, class_name: 'Attachment', foreign_key: 'attachable_id', dependent: :destroy

Rails 4:

has_one :banner_image, -> { where attachable_type: 'ThemeBannerAttachment'}, class_name: 'Attachment', dependent: :destroy

Not sure why, but in Rails 3, you need to supply a foreign_key value alongside the conditions and class_name. Do not use 'as: :attachable' as this will automatically use the calling class name when setting the polymorphic type.

The above applies to has_many too.

Solution 6 - Ruby on-Rails

I didn't use it, but I googled around and looked into Rails sources and I think that what you're looking for is :foreign_type. Try it and tell if it works :)

has_one :secondary_photo, :as => :attachable, :class_name => "Photo", :dependent => :destroy, :foreign_type => 'SecondaryPost'

I think that type in your question should be Post instead of Photo and, respectively, it would be better to use SecondaryPost as it assigned to Post model.

EDIT:

Above answer is completly wrong. :foreign_type is availble in polymorphic model in belongs_to association to specify name of the column that contains type of associated model.

As I look in Rails sources, this line sets this type for association:

dependent_conditions << "#{reflection.options[:as]}_type = '#{base_class.name}'" if reflection.options[:as]

As you can see it uses base_class.name to get type name. As far as I know you can do nothing with it.

So my sugestion is to add one column to Photo model, on example: photo_type. And set it to 0 if it is first photo, or set it to 1 if it is second photo. In your associations add :conditions => {:photo_type => 0} and :conditions => {:photo_type => 1}, respectively. I know it is not a solution you are looking for, but I can't find anything better. By the way, maybe it would be better to just use has_many association?

Solution 7 - Ruby on-Rails

Your going to have to monkey patch the notion of foreign_type into has_one relationship. This is what i did for has_many. In a new .rb file in your initializers folder i called mine add_foreign_type_support.rb It lets you specify what your attachable_type is to be. Example: has_many photo, :class_name => "Picture", :as => attachable, :foreign_type => 'Pic'

module ActiveRecord
  module Associations
    class HasManyAssociation < AssociationCollection #:nodoc:
      protected
        def construct_sql
          case
            when @reflection.options[:finder_sql]
              @finder_sql = interpolate_sql(@reflection.options[:finder_sql])
           when @reflection.options[:as]
              resource_type = @reflection.options[:foreign_type].to_s.camelize || @owner.class.base_class.name.to_s
              @finder_sql =  "#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_id = #{owner_quoted_id} AND "
              @finder_sql += "#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_type = #{@owner.class.quote_value(resource_type)}"
              else
                @finder_sql += ")"
              end
              @finder_sql << " AND (#{conditions})" if conditions
            
            else
              @finder_sql = "#{@reflection.quoted_table_name}.#{@reflection.primary_key_name} = #{owner_quoted_id}"
              @finder_sql << " AND (#{conditions})" if conditions
          end

          if @reflection.options[:counter_sql]
            @counter_sql = interpolate_sql(@reflection.options[:counter_sql])
          elsif @reflection.options[:finder_sql]
            # replace the SELECT clause with COUNT(*), preserving any hints within /* ... */
            @reflection.options[:counter_sql] = @reflection.options[:finder_sql].sub(/SELECT (\/\*.*?\*\/ )?(.*)\bFROM\b/im) { "SELECT #{$1}COUNT(*) FROM" }
            @counter_sql = interpolate_sql(@reflection.options[:counter_sql])
          else
            @counter_sql = @finder_sql
          end
        end
    end
  end
end
# Add foreign_type to options list
module ActiveRecord
  module Associations # :nodoc:
     module ClassMethods
      private
        mattr_accessor :valid_keys_for_has_many_association
        @@valid_keys_for_has_many_association = [          :class_name, :table_name, :foreign_key, :primary_key,           :dependent,          :select, :conditions, :include, :order, :group, :having, :limit, :offset,          :as, :foreign_type, :through, :source, :source_type,          :uniq,          :finder_sql, :counter_sql,          :before_add, :after_add, :before_remove, :after_remove,          :extend, :readonly,          :validate, :inverse_of        ]

    end
  end

Solution 8 - Ruby on-Rails

None of these solutions seem to work on Rails 5. For some reason, it looks like the behaviour around the association conditions has changed. When assigning the related object, the conditions don't seem to be used in the insert; only when reading the association.

My solution was to override the setter method for the association:

has_one :photo, -> { photo_type: 'primary_photo'},
        as: 'attachable',
        dependent: :destroy

def photo=(photo)
  photo.photo_type = 'primary_photo'
  super
end

Solution 9 - Ruby on-Rails

For mongoid use this solution

Had tough times after discovering this issue but got cool solution that works

Add to your Gemfile

> gem 'mongoid-multiple-polymorphic'

And this works like a charm:

  class Resource

  has_one :icon, as: :assetable, class_name: 'Asset', dependent: :destroy, autosave: true
  has_one :preview, as: :assetable, class_name: 'Asset', dependent: :destroy, autosave: true
  
  end

Solution 10 - Ruby on-Rails

Might be a bit late, but this might help someone so here is how I fix this (rails 5.2, ruby 2.6):

I added an enum, called kind to the model and then added the proper scope to the has_one association:

class Photo 
   belongs_to :attachable, :polymorphic => true
   enum kind: %i[first_photo secondary_photo]
end

class Post
   has_one :photo, -> { where(kind: :first_photo) }, :as => :attachable, :dependent => :destroy
   has_one :secondary_photo, -> { where(kind: :secondary_photo) }, :as => :attachable, :dependent => :destroy
end

The scope is needed because ActiveRecord can discriminate between the objects/association.

Hope the above helps! 

Solution 11 - Ruby on-Rails

Can you add a SecondaryPhoto model like:

class SecondaryPhoto < Photo
end

and then skip the :class_name from the has_one :secondary_photo?

Solution 12 - Ruby on-Rails

  has_one :photo, -> { where attachable_type: "Photo" }, foreign_key: :attachable_id, class_name: Attachment.to_s, dependent: :destroy
  has_one :logo, -> { where attachable_type: "Logo" }, foreign_key: :attachable_id, class_name: Attachment.to_s, dependent: :destroy

when attaching:

  ActiveRecord::Base.transaction do
     attachment = user.attachments.find( id )
     user.logo = attachment
     user.save

     attachment.update( attachable_type: "Logo" )
     attachment.save
  end

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
QuestionMatt RogishView Question on Stackoverflow
Solution 1 - Ruby on-RailsMax ChernyakView Answer on Stackoverflow
Solution 2 - Ruby on-RailsArtem AminovView Answer on Stackoverflow
Solution 3 - Ruby on-RailsPaul DanelliView Answer on Stackoverflow
Solution 4 - Ruby on-RailssangyongjungView Answer on Stackoverflow
Solution 5 - Ruby on-RailsJellyFishBoyView Answer on Stackoverflow
Solution 6 - Ruby on-RailsklewView Answer on Stackoverflow
Solution 7 - Ruby on-RailssimonslawView Answer on Stackoverflow
Solution 8 - Ruby on-RailsChris EdwardsView Answer on Stackoverflow
Solution 9 - Ruby on-RailsTim KozakView Answer on Stackoverflow
Solution 10 - Ruby on-Railsr4cc00nView Answer on Stackoverflow
Solution 11 - Ruby on-RailsRob BiedenharnView Answer on Stackoverflow
Solution 12 - Ruby on-RailsTim KozakView Answer on Stackoverflow