Why polymorphic association doesn't work for STI if type column of the polymorphic association doesn't point to the base model of STI?
Asked Answered
A

7

45

I have a case of polymorphic association and STI here.

# app/models/car.rb
class Car < ActiveRecord::Base
  belongs_to :borrowable, :polymorphic => true
end

# app/models/staff.rb
class Staff < ActiveRecord::Base
  has_one :car, :as => :borrowable, :dependent => :destroy
end

# app/models/guard.rb
class Guard < Staff
end

In order for the polymorphic assocation to work, according to the API documentation on Polymorphic Assocation, http://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html#label-Polymorphic+Associations that I have to set borrowable_type to the base_classof STI models, that is in my case is Staff.

The question is: Why doesn't it work if the borrowable_type set to STI class?

Some test to prove it:

# now the test speaks only truth

# test/fixtures/cars.yml
one:
  name: Enzo
  borrowable: staff (Staff)

two:
  name: Mustang
  borrowable: guard (Guard)

# test/fixtures/staffs.yml
staff:
  name: Jullia Gillard

guard:
  name: Joni Bravo
  type: Guard 

# test/units/car_test.rb

require 'test_helper'

class CarTest < ActiveSupport::TestCase
  setup do
    @staff = staffs(:staff)
    @guard = staffs(:guard) 
  end

  test "should be destroyed if an associated staff is destroyed" do
    assert_difference('Car.count', -1) do
      @staff.destroy
    end
  end

  test "should be destroyed if an associated guard is destroyed" do
    assert_difference('Car.count', -1) do
      @guard.destroy
    end
  end

end

But it seems to be true only with Staff instance. The results are:

# Running tests:

F.

Finished tests in 0.146657s, 13.6373 tests/s, 13.6373 assertions/s.

  1) Failure:
test_should_be_destroyed_if_an_associated_guard_is_destroyed(CarTest) [/private/tmp/guineapig/test/unit/car_test.rb:16]:
"Car.count" didn't change by -1.
<1> expected but was
<2>.

Thanks

Allurement answered 9/3, 2012 at 3:41 Comment(0)
P
38

Good question. I had exactly the same problem using Rails 3.1. Looks like you can not do this, because it does not work. Probably it is an intended behavior. Apparently, using polymorphic associations in combination with Single Table Inheritance (STI) in Rails is a bit complicated.

The current Rails documentation for Rails 3.2 gives this advice for combining polymorphic associations and STI:

Using polymorphic associations in combination with single table inheritance (STI) is a little tricky. In order for the associations to work as expected, ensure that you store the base model for the STI models in the type column of the polymorphic association.

In your case the base model would be "Staff", i.e. "borrowable_type" should be "Staff" for all items, not "Guard". It is possible to make the derived class appear as the base class by using "becomes" : guard.becomes(Staff). One could set the column "borrowable_type" directly to the base class "Staff", or as the Rails Documentation suggests, convert it automatically using

class Car < ActiveRecord::Base
  ..
  def borrowable_type=(sType)
     super(sType.to_s.classify.constantize.base_class.to_s)
  end
Pacificia answered 30/5, 2012 at 16:56 Comment(3)
So that means you cannot associate a Car with a Guard and retrieve it with @guard.car, because the Car table will always have its 'borrowable_type' column set to 'Staff', and never to 'Guard'. And that means that polymorphic associations on STI models are completely useless.Fredella
It is possible, this answer worked for me. Mmmm... it is strange it doesn't work out of the box. I cant see why.Platas
@Fredella your statement regarding @guard.car is false. Using the solution provided, Rails transparently uses the STI base classname Staff as the target type and due to the fact that id's are always unique in an STI table it makes no difference whether the type is set to Guard or to Staff in the poly table the correct record will be fetched. Going from @car.guard will return the respective Guard too. So STI is useful with polymorphism because not only will the STI model work (with the provided solution) other models can still relate to the polymorphic model as well as desired.Transient
G
19

An older question, but the issue in Rails 4 still remains. Another option is to dynamically create/overwrite the _type method with a concern. This would be useful if your app uses multiple polymorphic associations with STI and you want to keep the logic in one place.

This concern will grab all polymorphic associations and ensure that the record is always saved using the base class.

# models/concerns/single_table_polymorphic.rb
module SingleTablePolymorphic
  extend ActiveSupport::Concern

  included do
    self.reflect_on_all_associations.select{|a| a.options[:polymorphic]}.map(&:name).each do |name|
      define_method "#{name.to_s}_type=" do |class_name|
        super(class_name.constantize.base_class.name)
      end
    end
  end
end

Then just include it in your model:

class Car < ActiveRecord::Base
  belongs_to :borrowable, :polymorphic => true
  include SingleTablePolymorphic
end
Georgettageorgette answered 7/5, 2014 at 14:17 Comment(1)
It's important to add the concern directory to the load path: Adding a directory to the load path in Rails. Otherwise you will get an uninitialized constant error.Dysgraphia
D
18

Just had this issue in Rails 4.2. I found two ways to resolve:

--

The problem is that Rails uses the base_class name of the STI relationship.

The reason for this has been documented in the other answers, but the gist is that the core team seem to feel that you should be able to reference the table rather than the class for a polymorphic STI association.

I disagree with this idea, but am not part of the Rails Core team, so don't have much input into resolving it.

There are two ways to fix it:

--

1) Insert at model-level:

class Association < ActiveRecord::Base

  belongs_to :associatiable, polymorphic: true
  belongs_to :associated, polymorphic: true

  before_validation :set_type

  def set_type
    self.associated_type = associated.class.name
  end
end

This will change the {x}_type record before the creation of the data into the db. This works very well, and still retains the polymorphic nature of the association.

2) Override Core ActiveRecord methods

#app/config/initializers/sti_base.rb
require "active_record"
require "active_record_extension"
ActiveRecord::Base.store_base_sti_class = false

#lib/active_record_extension.rb
module ActiveRecordExtension #-> https://mcmap.net/q/149971/-rails-extending-activerecord-base

  extend ActiveSupport::Concern

  included do
    class_attribute :store_base_sti_class
    self.store_base_sti_class = true
  end
end

# include the extension 
ActiveRecord::Base.send(:include, ActiveRecordExtension)

####

module AddPolymorphic
  extend ActiveSupport::Concern
  
  included do #-> https://mcmap.net/q/374338/-overriding-methods-in-an-activesupport-concern-module-which-are-defined-by-a-class-method-in-the-same-module
    define_method :replace_keys do |record=nil|
      super(record)
      owner[reflection.foreign_type] = ActiveRecord::Base.store_base_sti_class ? record.class.base_class.name : record.class.name
    end
  end
end

ActiveRecord::Associations::BelongsToPolymorphicAssociation.send(:include, AddPolymorphic)

A more systemic way to fix the issue is to edit the ActiveRecord core methods which govern it. I used references in this gem to find out which elements needed to be fixed / overridden.

This is untested and still needs extensions for some of the other parts of the ActiveRecord core methods, but seems to work for my local system.

Denomination answered 8/4, 2015 at 9:20 Comment(3)
reference to related code in rails core: github.com/rails/rails/blob/master/activerecord/lib/…Reprehensible
For sure your solution did it. I wonder if there is a better way... in the documentation (api.rubyonrails.org/classes/ActiveRecord/Associations/…) it is stated to override the setter of the type method. However, it does not seem to work...Electorate
"but the gist is that the core team seems to feel that you should be able to reference the table rather than the class for a polymorphic STI association." Do you know where I could find the explanation given by the Rails core team?Waves
S
7

There is a gem. https://github.com/appfolio/store_base_sti_class

Tested and it works on various versions of AR.

Sweetie answered 27/10, 2015 at 8:30 Comment(0)
P
3

You can also build a custom scope for a has_* association for the polymorphic type:

class Staff < ActiveRecord::Base
  has_one :car, 
          ->(s) { where(cars: { borrowable_type: s.class }, # defaults to base_class
          foreign_key: :borrowable_id,
          :dependent => :destroy
end

Since polymorphic joins use a composite foreign key (*_id and *_type) you need to specify the type clause with the correct value. The _id though should work with just the foreign_key declaration specifying the name of the polymorphic association.

Because of the nature of polymorphism it can be frustrating to know what models are borrowables, since it could conceivably be any model in your Rails application. This relationship will need to be declared in any model where you want the cascade deletion on borrowable to be enforced.

Pristine answered 24/1, 2019 at 20:22 Comment(0)
F
2

This is how I solved that problem using aforementioned hints:

# app/models/concerns/belongs_to_single_table_polymorphic.rb

module BelongsToSingleTablePolymorphic
  extend ActiveSupport::Concern

  included do
    def self.belongs_to_sti_polymorphic(model)
      class_eval "belongs_to :#{model}, polymorphic: true"
      class_eval 'before_validation :set_sti_object_type'

      define_method('set_sti_object_type') do
        sti_type = send(model).class.name

        send("#{model}_type=", sti_type)
      end
    end
  end
end

and with that, for any model in which I would find belongs_to :whatever, polymorphic: true I do:

class Reservation < ActiveRecord::Base
  include BelongsToSingleTablePolymorphic
  # .....
  belongs_to_sti_polymorphic :whatever
  # .....
end
Flite answered 22/9, 2020 at 17:46 Comment(0)
B
1

I agree with the general comments that this ought to be easier. That said, here is what worked for me.

I have a model with Firm as the base class and Customer and Prospect as the STI classes, as so:

class Firm
end

class Customer < Firm
end

class Prospect < Firm
end

I also have a polymorphic class, Opportunity, which looks like this:

class Opportunity
  belongs_to :opportunistic, polymorphic: true
end

I want to refer to opportunities as either

customer.opportunities

or

prospect.opportunities

To do that I changed the models as follows.

class Firm
  has_many opportunities, as: :opportunistic
end

class Opportunity
  belongs_to :customer, class_name: 'Firm', foreign_key: :opportunistic_id
  belongs_to :prospect, class_name: 'Firm', foreign_key: :opportunistic_id
end

I save opportunities with an opportunistic_type of 'Firm' (the base class) and the respective customer or prospect id as the opportunistic_id.

Now I can get customer.opportunities and prospect.opportunities exactly as I want.

Bias answered 5/7, 2014 at 9:38 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.