29

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?

Benjie
  • 7,701
  • 5
  • 29
  • 44
  • 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... – Beni Cherniavsky-Paskin May 16 '17 at 12:09

6 Answers6

49

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.

StackNG
  • 581
  • 7
  • 8
  • 1
    This works well - thanks for sharing! It's a bit bizarre that this is necessary, really... – Benjie Oct 03 '14 at 00:55
  • 2
    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: https://github.com/rails/rails/pull/8357 – Nick Nov 29 '16 at 22:00
  • 4
    Without self_ref: belongs_to : volunteer, foreign_key: : subject_id, foreign_type: : subject_type, class_name: Volunteer, polymorphic: true – bondarenko.dev May 19 '17 at 12:15
  • 2
    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. – Krisztián Szabó Jul 06 '18 at 13:35
  • @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" – Andrew H Sep 25 '20 at 20:15
  • 1
    @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. – Andrew H Sep 25 '20 at 21:00
  • `:class_name => self` didn't work for me. Had to use `:class_name => 'Note'` – Alan Eduardo Covarrubias Jun 24 '21 at 06:17
  • 1
    `class_name: self.name` works as well. – Fishtoaster Dec 02 '21 at 18:57
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

Slicedpan
  • 4,995
  • 2
  • 18
  • 33
  • 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! – Benjie Feb 18 '14 at 14:31
  • @Benjie Edited answer with alternate method (might work in 3.2) – Slicedpan Feb 18 '14 at 15:19
  • 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. -- http://guides.rubyonrails.org/3_1_release_notes.html – Benjie Mar 11 '14 at 11:43
5

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'
skilleo
  • 2,451
  • 1
  • 27
  • 34
  • 13
    For me this approach "runs" but the SQL resulting from `joins` doesn't actually constrain the `subject_type` [Rails 5.0] – Beni Cherniavsky-Paskin May 16 '17 at 11:54
  • 3
    @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 match – 23tux May 05 '19 at 13:53
  • 3
    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 useful – alvesoaj Jul 24 '19 at 12:48
  • 2
    Doesn't work for rails 6.1 anymore. `foreign_type` not allowed to be used. – Dmitry Polushkin Jun 25 '21 at 14:44
  • @DmitryPolushkin is there an equivalent way to accomplish in rails 6.x? – skilleo Jun 30 '21 at 22:29
  • As I remember this one worked well for me: https://stackoverflow.com/a/40132681/409016 – Dmitry Polushkin Jul 01 '21 at 06:59
  • 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 `subject`s of different classes have the same id. https://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html#method-i-belongs_to-label-Options – Fishtoaster Dec 02 '21 at 19:03
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.

  • didn't you need `optional: true` ? – Vasco May 26 '20 at 03:47
  • I think you can use `joins` instead of `includes` – that should be enough. – Matt Oct 12 '20 at 13:52
  • @Matt 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. – Nathan Wallace Jun 09 '21 at 20:15
  • 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 this – Nathan Wallace Jun 09 '21 at 20:25
2

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

I will say, it is a hard problem to find an answer to, as it is hard to figure out how to ask the question, and also hard to weed out all the articles that are just standard Rails answers. 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. Figured I'd post this anyway as it has been the result of a very thorough investigation and might help somebody else understand how this stuff works.

Andrew H
  • 517
  • 6
  • 14
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
magni-
  • 1,934
  • 17
  • 20