Rails Polymorphic Association with multiple associations on the same model
Asked Answered
N

14

64

My question is essentially the same as this one: 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!

Nealon answered 22/3, 2010 at 17:41 Comment(1)
This how to may also help: github.com/tokhi/polymorphism-in-railsTripod
T
76

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.

Tater answered 20/6, 2010 at 5:5 Comment(14)
I like the idea a lot, only problem is I can't get rails to write the correct value into the photo_type column, it's always Photo. Am I missing something, how do you get rails to write 'primary photo' into the photo_type column?Edging
What's the name of your column? Make sure it's not just type since that's a special column rails uses to write the name of current class.Tater
The above code didn't work for me. The :conditions => {:photo_type => 'primary_photo'} didn't change the photo_type column content. It was still the class of my model. What did work was add a new column to the database, photo_sub_type (string), and set my :conditions => {:photo_sub_type => 'primary_photo'} but then every association has to have the :conditions => {:photo_sub_type => [some value here]} specified. But it works just as I wanted it to. Use the same Photo model to attach many different types of photos to another model.Tutty
The above code works, BUT: The reason you're having problems, Rob, is that the above code assumes you are doing Single Table Inheritance in your modelFeigned
@Feigned wait, where does it assume that?Tater
@hakunin I'm assuming you have two different types of Photo classes, say FirstPhoto and SecondaryPhoto that both derive from Photo?Feigned
@Feigned Look carefully. In both associations :class_name is Photo (implicitly for :photo, explicitly for :secondary_photo). The only thing you need is to have a photo_type column on photos table. This column will not make photos STI, you would need a type column for that. The only reason you need it is that by using has_one you can't distinguish associations based on order, so you have to use some other indication. photo_type is there for that purpose only, to help has_one distinguish between 2 otherwise indistinguishable rows in the photos table.Tater
Ah, got it. Sorry for the confusionFeigned
For anyone struggling with this solution: make sure you set attr_accessible :photo_type in your Photo model, otherwise your Post model won't be able to automatically populate it using :conditions. Had me stuck for nearly an hour there.Mllly
Is there a way to do the same thing since that conditions is deprecated in Rails 4?Usable
@Usable I think a scope block should do the trick.Incognito
Rails 4: has_one :photo, -> { where photo_type: "primary_photo" }, dependent: :destroyFollowing
@DamienRoche I tried to do as you stated, but I am not able to use primary_model_object.polymorphic_associated_object.create, as interface_type attribute I always get primary_model name. Is it possible to make rails correctly fill interface_type attribute based on where condition? Thank a lot for suggestions.Yablon
Also when I try to query with scope block it looks like: SELECT "generic_items".* FROM "generic_items" WHERE "generic_items"."itemable_id" = ? AND "generic_items"."itemable_type" = ? AND "generic_items"."itemable_type" = ? [["itemable_id", 1], ["itemable_type", "Project"], ["itemable_type", "project_goals"]]Yablon
V
28

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

Varitype answered 1/2, 2015 at 7:37 Comment(4)
in my case imageable_type is not setting accordingly what can be reason?Ascetic
I have also asked this #34329235Ascetic
this doesn't seem to work on Rails 5 & 6. Any suggestions on what might need to change?Swiercz
I solved it here https://mcmap.net/q/300450/-rails-polymorphic-association-with-multiple-associations-on-the-same-modelSacristy
R
12

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.

Rosabelle answered 4/4, 2017 at 11:26 Comment(3)
this doesn't seem to work on Rails 5 & 6. Any suggestions on what might need to change?Swiercz
@Swiercz this is still working in a large rails app that’s been moved from 4.2 to 5.0 Xto 6.1Rosabelle
@Swiercz just FYI, this is still working for me in a large rails app that’s been moved from 4.2 to 5.x to 6.1.Rosabelle
P
6

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
Patric answered 22/10, 2014 at 17:31 Comment(2)
foreign_key is the key (haha) here that other answers alluded to. This works in Rails 4 for me.Viafore
Yes, when creating factories I've had to explicitly set the address_holder_typeViafore
D
5

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.

Decury answered 13/10, 2014 at 14:6 Comment(0)
Y
3

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?

Yale answered 22/3, 2010 at 20:47 Comment(4)
Unfortunately Rails 2.1 doesn't have foreign_typeNealon
I think it was added around 2.3. So I think that there is no other way of doing what you want. You can try adding this feature manualy or, what is much better, upgrade your application to 2.3.5 and always stay on latest version.Yale
I tried w/Rails 2.3.5 and it still tells me unknown key: foreign_type :(Nealon
Take a look at associations.rb valid_keys_for_has_one_association :class_name, :foreign_key, :remote, :select, :conditions, :order, :include, :dependent, :counter_cache, :extend, :as, :readonly, :validate, :primary_keyNealon
W
2

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
Washington answered 10/6, 2010 at 6:39 Comment(1)
This is the code I'm using with my rails 2.3.8 application. I would say step threw the logic and see if you have to make adjustments. The idea is your pass a foreign_type options params and if that exists you overload the default behavor on the select statement you might have to to_s.camelize the value if you pass it in as a :symbolWashington
C
2

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
Cheslie answered 26/10, 2017 at 9:0 Comment(2)
Nice idea, but instead of using the photo_type I prefer to create photo_scope (or in my case it was addressable_scope) to make the belongs_to call backwards compatipable.Underfoot
So would you have two methods to override both primary and secondary photo? Does the insertion work then?Swiercz
Z
2

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! 👌

Zedoary answered 11/1, 2021 at 15:23 Comment(0)
S
1

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
Sacristy answered 14/6, 2015 at 15:7 Comment(0)
I
0

Can you add a SecondaryPhoto model like:

class SecondaryPhoto < Photo
end

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

Ingeminate answered 22/3, 2010 at 20:19 Comment(3)
That does seem like a workable solution - I was hoping there was a "built-in" way to do what I wanted....Nealon
I tried that - it passes Photo as the type just as if I did class_name!Nealon
How are you "attaching" the secondary photo to the Post? In any case, I'm curious to know if klew's answer about :foreign_type works.Ingeminate
S
0
  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
Sacristy answered 5/4, 2021 at 12:8 Comment(0)
S
0

One way to approach this is to add a role field to your Photo model. This role field can store information about whether a Photo is a primary photo or a secondary photo. It's not ideal because it would require modifying your existing model and any related code, but it might work for your situation.

Here is an example how you can implement it:

You'd need to add a migration:

class AddRoleToPhotos < ActiveRecord::Migration[6.1]
  def change
    add_column :photos, :role, :string
  end
end

And you'd need to add a default scope to your Photo model:

class Photo < ActiveRecord::Base
  belongs_to :attachable, :polymorphic => true
  scope :primary, -> { where(role: 'primary') }
  scope :secondary, -> { where(role: 'secondary') }
end

Your Post model would look like this:

class Post < ActiveRecord::Base
  has_one :photo, -> { primary }, :as => :attachable, :class_name => "Photo", :dependent => :destroy
  has_one :secondary_photo, -> { secondary }, :as => :attachable, :class_name => "Photo", :dependent => :destroy
end

This will generate the correct SQL to distinguish between primary and secondary photos.

A couple of points to be aware of:

  1. You need to ensure you set the role attribute correctly when creating Photo objects.
  2. If you have existing Photo objects, you'll need to backfill the role attribute for them.

While this approach would require modifying the model and backfilling data, it should solve your issue. It would also avoid the need to create new Photo classes (like SecondaryPhoto), which can simplify the codebase.

Shrink answered 6/6, 2023 at 22:58 Comment(0)
L
-1

Using rails 7, I used STI to solve this problem. This approach is simple and cleaner as I didn't have to state the class_name: in the association and the document type gets populated automatically.

# migration file
class CreateDocuments < ActiveRecord::Migration[7.0]
  def change
    create_table :documents, id: :uuid do |t|
      t.string :type, null: false
      t.references :entity, type: :uuid, polymorphic: true
      ...
    end
  end
end

# Document model i.e the base model
class Document < ApplicationRecord
  belongs_to :entity, polymorphic: true
  ...
end

# Sub classes of Document
class InsuranceSticker < Document
end

class RoadWorthyCertificate < Document
end
...


# Vehicles and Drivers are entities that can hold documents
class Vehicle < ApplicationRecord
  has_one :insurance_sticker, as: :entity, dependent: :destroy
  has_one :road_worthy_certificate, as: :entity, dependent: :destroy

  # nested
  accepts_nested_attributes_for(:insurance_sticker, :road_worthy_certificate)
  ...
end

class Driver < User
  has_one drivers_license, as: :entity, dependent: :destroy

  # nested
  accepts_nested_attributes_for(
    :drivers_license,
    :reverse_side_of_drivers_license
  )
end
Leaving answered 1/10, 2022 at 20:7 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.