Association for polymorphic belongs_to of a particular type
Asked Answered
H

7

31

I'm relatively new to Rails. I would like to add an association to a model that uses the polymorphic association, but returns only models of a particular type, e.g.:

class Note < ActiveRecord::Base
  # The true polymorphic association
  belongs_to :subject, polymorphic: true

  # Same as subject but where subject_type is 'Volunteer'
  belongs_to :volunteer, source_association: :subject
  # Same as subject but where subject_type is 'Participation'
  belongs_to :participation, source_association: :subject
end

I've tried a vast array of combinations from reading about the associations on ApiDock but nothing seems to do exactly what I want. Here's the best I have so far:

class Note < ActiveRecord::Base
  belongs_to :subject, polymorphic: true
  belongs_to :volunteer, class_name: "Volunteer", foreign_key: :subject_id, conditions: {notes: {subject_type: "Volunteer"}}
  belongs_to :participation, class_name: "Participation", foreign_key: :subject_id, conditions: {notes: {subject_type: "Participation"}}
end

And I want it to pass this test:

describe Note do
  context 'on volunteer' do
    let!(:volunteer) { create(:volunteer) }
    let!(:note) { create(:note, subject: volunteer) }
    let!(:unrelated_note) { create(:note) }

    it 'narrows note scope to volunteer' do
      scoped = Note.scoped
      scoped = scoped.joins(:volunteer).where(volunteers: {id: volunteer.id})
      expect(scoped.count).to be 1
      expect(scoped.first.id).to eq note.id
    end

    it 'allows access to the volunteer' do
      expect(note.volunteer).to eq volunteer
    end

    it 'does not return participation' do
      expect(note.participation).to be_nil
    end

  end
end

The first test passes, but you can't call the relation directly:

  1) Note on volunteer allows access to the volunteer
     Failure/Error: expect(note.reload.volunteer).to eq volunteer
     ActiveRecord::StatementInvalid:
       PG::Error: ERROR:  missing FROM-clause entry for table "notes"
       LINE 1: ...."deleted" = 'f' AND "volunteers"."id" = 7798 AND "notes"."s...
                                                                    ^
       : SELECT  "volunteers".* FROM "volunteers"  WHERE "volunteers"."deleted" = 'f' AND "volunteers"."id" = 7798 AND "notes"."subject_type" = 'Volunteer' LIMIT 1
     # ./spec/models/note_spec.rb:10:in `block (3 levels) in <top (required)>'

Why?

The reason I want to do it this way is because I'm constructing a scope based on parsing a query string including joining to various models/etc; the code used to construct the scope is considerably more complex than that above - it uses collection.reflections, etc. My current solution works for this, but it offends me I can't call the relations directly from an instance of Note.

I could solve it by splitting it into two issues: using scopes directly

  scope :scoped_by_volunteer_id, lambda { |volunteer_id| where({subject_type: 'Volunteer', subject_id: volunteer_id}) }
  scope :scoped_by_participation_id, lambda { |participation_id| where({subject_type: 'Participation', subject_id: participation_id}) }

and then just using a getter for note.volunteer/note.participation that just returns note.subject if it has the right subject_type (nil otherwise) but I figured in Rails there must be a better way?

Hamish answered 18/2, 2014 at 10:31 Comment(1)
The somewhat cryptic {notes: {subject_type: "Volunteer"}} clause has the notes: because otherwise it would query on non-existant volunteers.subject_type column, this makes it query on notes.subjects_type. I've settled on equivalent {'notes.subject_type': "Volunteer"} syntax; it also reminds me the notes should be a plural table name, not the (sometimes singular) association name...Dangelo
C
50

I had bump into the similar problem. and I finally ironed out the best and most robust solution by using a self reference association like below.

class Note < ActiveRecord::Base
  # The true polymorphic association
  belongs_to :subject, polymorphic: true

  # The trick to solve this problem
  has_one :self_ref, :class_name => self, :foreign_key => :id

  has_one :volunteer, :through => :self_ref, :source => :subject, :source_type => Volunteer
  has_one :participation, :through => :self_ref, :source => :subject, :source_type => Participation
end

Clean & simple, only tested on Rails 4.1, but I guess it should work for previous versions.

Cupbearer answered 22/9, 2014 at 4:16 Comment(8)
This works well - thanks for sharing! It's a bit bizarre that this is necessary, really...Hamish
Unrelated to the question, but relevant to this answer: I believe using constants in association definitions is advised against. Instead of Volunteer and Participation, you'd want to use "Volunteer" or :Volunteer and "Participation" or :Participation. Discussion here: github.com/rails/rails/pull/8357Hypognathous
Without self_ref: belongs_to : volunteer, foreign_key: : subject_id, foreign_type: : subject_type, class_name: Volunteer, polymorphic: trueBreakable
Assignment doesn't work correctly this way. If you do Note.create(volunteer: Volunteer.create) then subject_id and subject_type will be nil, although you can still retrieve the volunteer from the read method.Portemonnaie
@bondarenko.dev: your solution causes a problem when subject_type is not a "Volunteer" - it simply returns an instance of the class that subject_type refers to, even though the code seems to tell it that it should only return classes of "Volunteer"Cud
@stackNG, this is a nice solution and probably the cleanest I've found on this problem. My one issue is that it adds an extra join that shouldn't be necessary. I wish it weren't necessary, and I am in the process of posting an alternative solution below, but it is not as simple, but does do away with extra queries.Cud
:class_name => self didn't work for me. Had to use :class_name => 'Note'Letrice
class_name: self.name works as well.Alight
N
8

I was stuck on this sort of reverse association and in Rails 4.2.1 I finally discovered this. Hopefully this helps someone if they're using a newer version of Rails. Your question was the closest to anything I found in regard to the issue I was having.

belongs_to :volunteer, foreign_key: :subject_id, foreign_type: 'Volunteer'
Natividadnativism answered 14/8, 2015 at 21:31 Comment(7)
For me this approach "runs" but the SQL resulting from joins doesn't actually constrain the subject_type [Rails 5.0]Dangelo
@BeniCherniavsky-Paskin I stumbled upon the same problem, if there is another model with the same id, it just returns this event when the type doesn't matchBelemnite
To solve the problem of subject_type not being included on join, I made this workaround: belongs_to :volunteer, -> { where(notes: { subject_type: :Volunteer }) }, foreign_key: :subject_id, foreign_type: :Volunteer, optional: true I hope it may be usefulCoremaker
Doesn't work for rails 6.1 anymore. foreign_type not allowed to be used.Richter
@DmitryPolushkin is there an equivalent way to accomplish in rails 6.x?Natividadnativism
As I remember this one worked well for me: https://mcmap.net/q/460895/-association-for-polymorphic-belongs_to-of-a-particular-typeRichter
As far as I can tell, this approach does not work. According to the docs, foreign_type is meant to hold a column name, not a class name. It runs, but does not produce the correct query and fails when two subjects of different classes have the same id. api.rubyonrails.org/classes/ActiveRecord/Associations/…Alight
W
6

I have found a hackish way of getting around this issue. I have a similar use case in a project of mine, and I found this to work. In your Note model you can add associations like this:

class Note
  belongs_to :volunteer, 
    ->(note) {where('1 = ?', (note.subject_type == 'Volunteer')},
    :foreign_key => 'subject_id'
end

You will need to add one of these for each model that you wish to attach notes to. To make this process DRYer I would recommend creating a module like so:

 module Notable
   def self.included(other)
     Note.belongs_to(other.to_s.underscore.to_sym, 
       ->(note) {where('1 = ?', note.subject_type == other.to_s)},
       {:foreign_key => :subject_id})
   end
 end

Then include this in your Volunteer and Participation models.

[EDIT]

A slightly better lambda would be:

 ->(note) {(note.subject_type == "Volunteer") ? where('1 = 1') : none}

For some reason replacing the 'where' with 'all' does not seem to work. Also note that 'none' is only available in Rails 4.

[MOAR EDIT]

I'm not running rails 3.2 atm so I can't test, but I think you can achieve a similar result by using a Proc for conditions, something like:

belongs_to :volunteer, :foreign_key => :subject_id, 
  :conditions => Proc.new {['1 = ?', (subject_type == 'Volunteer')]}

Might be worth a shot

Whilst answered 18/2, 2014 at 12:25 Comment(3)
Interesting hack, sadly Rails 3.2 doesn't seem to allow a scope as part of a belongs_to relation. :( (Upgrading to Rails 4 is on my todo list, but there so much other stuff to do first...) Great idea though!Hamish
@Hamish Edited answer with alternate method (might work in 3.2)Whilst
Thanks for the new attempt - sorry it's taken so long for me to try it out. Sadly it doesn't seem to work (undefined method subject_type), I think it's because: > Inside the proc, self is the object which is the owner of the association, unless you are eager loading the association, in which case self is the class which the association is within. -- guides.rubyonrails.org/3_1_release_notes.htmlHamish
V
3

You can do like so:

belongs_to :volunteer, -> {
  where(notes: { subject_type: 'Volunteer' }).includes(:notes)
}, foreign_key: :subject_id

The includes do the left join so you have the notes relation with all volunteer and participation. And you go through subject_id to find your record.

Vanillic answered 19/10, 2016 at 13:23 Comment(4)
didn't you need optional: true ?Sequacious
I think you can use joins instead of includes – that should be enough.Viator
@Viator I tried it with joins (because of course that makes sense), but joins doesn't work exactly right, because it can result in duplicate rows being returned. I tried adding distinct afterward also, but that still didn't work. I don't know the details, but includes worked correctly, not returning duplicate records.Agricola
This doesn't work right when the Note's subject is a different type of object, but has the same ID as an existing Volunteer. E.g. for a Note (call it a_note) with attributes subject_type: "Participation", subject_id: 1, and given the database contains both a Participation and Volunteer record with id: 1, then a_note.volunteer will return the Volunteer record, rather than nil, even though a_note.subject_type is "Participation". Will update here if I figure out a fix for thisAgricola
C
2

I believe I have figured out a decent way to handle this that also covers most use cases that one might need.

I think this problem falls into the advanced ActiveRecord realm.

Essentially what we are trying to do is to add a relationship to the model and only use that association if certain prerequisites are met on the model where the association is made. For example, if I have class SomeModel, and it has belongs_to association called "some_association", we might want to apply some prerequisite conditions that must be true on the SomeModel record that influence whether :some_association returns a result or not. In the case of a polymorphic relationship, the prerequisite condition is that the polymorphic type column is a particular value, and if not that value, it should return nil.

The difficulty of solving this problem is compounded by the different ways. I know of three different modes of access: direct access on an instance (ex: SomeModel.first.some_association), :joins (Ex: SomeModel.joins(:some_association), and :includes (Ex: SomeModel.includes(:some_association)) (note: eager_load is just a variation on joins). Each of these cases needs to be handled in a specific way.

Today, as I've essentially been revisiting this problem, I came up with the following utility method that acts as a kind of wrapper method for belongs_to. I'm guessing a similar approach could be used for other association types.

  # WARNING: the joiner table must not be aliased to something else in the query,
  #  A parent / child relationship on the same table probably would not work here
  # TODO: figure out how to support a second argument scope being passed
  def self.belongs_to_with_prerequisites(name, prerequisites: {}, **options)
    base_class = self
    belongs_to name, -> (object=nil) {
      # For the following explanation, assume we have an ActiveRecord class "SomeModel" that has a belongs_to
      #   relationship on it called "some_association"
      # Object will be one of the following:
      #   * nil - when this association is loaded via an :includes.
      #     For example, SomeModel.includes(:some_association)
      #   * an model instance - when this association is called directly on the referring model
      #     For example: SomeModel.first.some_association, object will equal SomeModel.first
      #   * A JoinDependency - when we are joining this association
      #     For example, SomeModel.joins(:some_assocation)
      if !object.is_a?(base_class)
        where(base_class.table_name => prerequisites)
      elsif prerequisites.all? {|name, value| object.send(name) == value}
        self
      else
        none
      end
    },
    options
  end

That method would need to be injected into ActiveRecord::Base.

Then we could use it like:

  belongs_to_with_prerequisites :volunteer,
    prerequisites: { subject_type: 'Volunteer' },
    polymorphic: true,
    foreign_type: :subject_type,
    foreign_key: :subject_id

And it would allow us to do the following:

Note.first.volunteer
Note.joins(:volunteer)
Note.eager_load(:volunteer)

However, we'll get an error if we try to do this:

Note.includes(:volunteer)

If we run that last bit of code, it will tell us that the column subject_type does not exist on the volunteers table.

So we'd have to add a scope to the Notes class and use as follows:

class Note < ActiveRecord::Base
  belongs_to_with_prerequisites :volunteer,
        prerequisites: { subject_type: 'Volunteer' },
        polymorphic: true,
        foreign_type: :subject_type,
        foreign_key: :subject_id
  scope :with_volunteer, -> { includes(:volunteer).references(:volunteer) }
end
Note.with_volunteer

So at the end of the day, we don't have the extra join table that @stackNG's solution had, but that solution was definitely more eloquent and less hacky.

Cud answered 25/9, 2020 at 21:11 Comment(0)
T
1

Here's a different option using a parameterized scope. Based on the foreign type, the scope will be set to either all or none so that the relation returns the related model if it's of the right type and nil otherwise:

class Note < ActiveRecord::Base
  belongs_to :subject, polymorphic: true

  belongs_to :volunteer, 
    -> (note) { note.subject_type == "Volunteer" ? all : none },
    foreign_key: :subject_id
  
  belongs_to :participation,
    -> (note) { note.subject_type == "Participation" ? all : none },
    foreign_key: :subject_id
end
Touchhole answered 8/12, 2022 at 14:27 Comment(1)
This may appear to work at first but (Ruby 3.1, Rails 7) you cannot assign a "subject" using the named association. You can read note.volunteer just fine but if you set it, only the subject_id is set, not the type.Coulter
B
0

I tried all the solutions listed here for Rails 7 and none of them worked so I thought I'd add yet another answer.

class Note < ApplicationRecord
  belongs_to :subject, polymorphic: true
  belongs_to :volunteer, -> { includes(:note).where(notes: { source_type: :Volunteer}) }, foreign_key: :subject_id, optional: true
  belongs_to :participation, -> { includes(:note).where(notes: { source_type: :Participation}) }, foreign_key: :subject_id, optional: true
end

class Volunteer < ApplicationRecord
  has_one :note, :as => :subject
end
#Do Participation same as Volunteer

With this solution if you call note.participation on a Note where the subject is actually a Volunteer you will get nil. Other solutions here will give an error or, horribly, a Participation. Also importantly if you join with this association it will work. Something like note.includes(:volunteer).where(volunteer: {volunteer_attribute: value}) will actually join the tables correctly.

Bezoar answered 10/9, 2023 at 2:16 Comment(1)
Same here. I can read note.volunteer just fine but if I set it, only the subject_id is set, not the type. E.g. Note.create!(volunteer: a_volunteer) and PG will raise a NotNullViolation. (Or I just get silent nulls in the db and orphaned data)Coulter

© 2022 - 2024 — McMap. All rights reserved.