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.