62

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!

Community
  • 1
  • 1
Matt Rogish
  • 24,435
  • 11
  • 76
  • 92

14 Answers14

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.

Gabe Kopley
  • 16,281
  • 5
  • 47
  • 60
Max Chernyak
  • 37,015
  • 6
  • 38
  • 43
  • 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? – opsb Jul 13 '10 at 21:39
  • 1
    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. – Max Chernyak Jul 14 '10 at 00:54
  • 6
    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. – Rob Sutherland Jan 20 '11 at 15:09
  • 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 model – Houen Sep 17 '11 at 16:46
  • @Houen wait, where does it assume that? – Max Chernyak Sep 17 '11 at 17:52
  • @hakunin I'm assuming you have two different types of Photo classes, say FirstPhoto and SecondaryPhoto that both derive from Photo? – Houen Sep 17 '11 at 18:37
  • 1
    @Houen 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. – Max Chernyak Sep 17 '11 at 19:13
  • Ah, got it. Sorry for the confusion – Houen Sep 17 '11 at 19:35
  • 4
    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. – Rob d'Apice Jan 04 '12 at 06:26
  • 13
    Is there a way to do the same thing since that `conditions` is deprecated in Rails 4? – TimPetricola Sep 20 '13 at 15:31
  • 1
    @TimPetricola I think a [scope block](http://guides.rubyonrails.org/association_basics.html#scopes-for-has-one) should do the trick. – Brian Mar 14 '14 at 16:49
  • 1
    Rails 4: `has_one :photo, -> { where photo_type: "primary_photo" }, dependent: :destroy` – Damien Roche May 08 '14 at 17:58
  • @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. – kasi May 28 '15 at 19:38
  • 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"]]` – kasi May 28 '15 at 19:47
27

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

Artem Aminov
  • 509
  • 6
  • 9
11

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.

Paul Danelli
  • 994
  • 1
  • 15
  • 25
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
sangyongjung
  • 161
  • 1
  • 5
  • `foreign_key` is the key (haha) here that other answers alluded to. This works in Rails 4 for me. – PhilT Jan 22 '15 at 11:55
  • 1
    Yes, when creating factories I've had to explicitly set the `address_holder_type` – PhilT Jan 22 '15 at 12:02
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.

JellyFishBoy
  • 1,658
  • 1
  • 17
  • 25
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?

klew
  • 14,837
  • 7
  • 47
  • 59
  • Unfortunately Rails 2.1 doesn't have foreign_type – Matt Rogish Mar 22 '10 at 21:00
  • 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. – klew Mar 22 '10 at 21:09
  • I tried w/Rails 2.3.5 and it still tells me unknown key: foreign_type :( – Matt Rogish Mar 23 '10 at 16:20
  • 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_key – Matt Rogish Mar 23 '10 at 16:29
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
simonslaw
  • 21
  • 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 :symbol – simonslaw Jul 22 '10 at 17:09
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
Chris Edwards
  • 3,514
  • 2
  • 33
  • 40
  • 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. – Dmitry Polushkin Oct 25 '19 at 00:40
  • So would you have two methods to override both primary and secondary photo? Does the insertion work then? – geoboy Jun 11 '20 at 22:07
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!

r4cc00n
  • 1,927
  • 1
  • 8
  • 24
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
Tim Kozak
  • 4,026
  • 39
  • 44
0

Can you add a SecondaryPhoto model like:

class SecondaryPhoto < Photo
end

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

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
Tim Kozak
  • 4,026
  • 39
  • 44
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.

sparkle
  • 7,530
  • 22
  • 69
  • 131
-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
Kofi Asare
  • 97
  • 1
  • 5