monomorphic
Fully featured joining to subtypes of polymorphic associations
Join to a a single resource type in a polymorphic assocaition
belongs_to :resource, polymorphic: true
is great, but don’t tell me you’ve never wanted to scope that to where(resource_type: "Foo")
. To use Bar.joins(:foo)
when :foo
is one of many resource_types.
You probably already found out you can’t bar.joins(:resource)
. This make sense, SQL can’t perform a join on each row to multiople table depending on resource_type
. The resulting joined table structure would be nonsensical. But in this case, you only want to join to the foos
table, so why can’t you do it? Because .joins(:resource)
needs to evaluate to something, and it can’t.
Write your own join? Then you lose all the benefits of Rails associations, including eager loading.
Somewhere ((here)[]?) you may have found a solution I did as well:
class Bar
belongs_to :foo, -> { where(bars: {resource_type: 'Foo'}) }, foreign_key: 'resource_id'
end
Yay:
Bar.joins(:foo)
# => #<ActiveRecord::Relation [#<Bar id: 1, resource_type: "Foo", resource_id: 1, foo_id: 1, deleted: false>]>
Boo:
Bar.first.foo
# => ActiveRecord::StatementInvalid: Mysql2::Error: Unknown column 'bars.resource_type' in 'where clause': SELECT `foos`.* FROM `foos` WHERE `foos`.`deleted` = 0 AND `foos`.`id` = 1 AND `bars`.`resource_type` = 'Foo' LIMIT 1
Huh? No column bars.resource_type? Sure: the SQL generated by the association getter method only looks for a Foo
with the matching id. It doesn’t join to, and therefore doesn’t have access to, the bars
table. However we have added a scope that references the bars
table that we are not joining to, and so the SQL statement balks. So that means the fix is simple:
class Bar
belongs_to :foo, -> { joins(:bar).where(bars: {resource_type: 'Foo'}) }, foreign_key: 'resource_id'
end
Provided, of course, the :bar
assocaiton is set up correclty on class Foo
. Note that this may be a has_one
or a has_many
for your application, and for the latter of course you need to joins(:bars)
instead of joins(:bar)
.
Of course we could just use Bar.first.resource
, but it doesn’t feel right to leave this dangling error-prone method out there.
Yay:
Bar.first.foo
# => #<Foo id: 1, deleted: false>
Bar.preload(:foo).first
# => #<Bar id: 1, resource_type: "Foo", resource_id: 1, foo_id: 1, deleted: false>
So we’re done … ? Nope. There’s one more complication. Let’s add another resource_type to the mix:
class Bar
belongs_to :foo, -> { joins(:bar).where(bars: {resource_type: 'Foo'}) }, foreign_key: 'resource_id'
belongs_to :baz, -> { joins(:bar).where(bars: {resource_type: 'Baz'}) }, foreign_key: 'resource_id'
end
baz = Baz.create
bar2 = Bar.new
bar2.resource = baz
bar2.save
The existence of bar2 is critical in disvoering the following bug, which we’ll explain just below.
Boo:
Bar.first
# => #<Bar id: 1, resource_type: "Foo", resource_id: 1, foo_id: 1, deleted: false>
Bar.first.resource
# => #<Foo id: 1, deleted: false>
Bar.first.foo
# => #<Foo id: 1, deleted: false>
Bar.first.baz
# => #<Baz id: 1>
Bar.first
’s resource is a Foo
, not a Baz
, and yet it’s incorrectly returning to us a Baz
when we ask for it! Bar.first
does not have a Baz
, so we should be getting nil
here. What’s happening is that when ActiveRecord is generates the query for bar.baz
, once again it drives the query off of the bazes
table with poor regard for the bars
table:
SELECT `bazs`.* FROM `bazs` INNER JOIN `bars` ON `bars`.`resource_id` = `bazs`.`id` AND `bars`.`deleted` = 0 AND `bars`.`resource_type` = 'Baz' WHERE `bazs`.`id` = 1 AND `bars`.`resource_type` = 'Baz' LIMIT 1
MySQL is joining bazs
to bars
, because we told it to, fine, and it’s checking for resource_type = 'Baz'
. So what’s going wrong? Let’s take a closer look at the bars
table.
id | resource_type | resource_id |
---|---|---|
1 | "Foo" | 1 |
2 | "Baz" | 1 |
Our SQL statement is trying to join the Baz
with id 1 to a corresponding bar
with resource_type “Baz” and resource_id 1. Well there it is, in the second row, with id 2. We don’t want that one, because we’re explicitly calling the method from the Bar
with id 1. But the getter method for baz
is not given that information.
We want a way to add AND `bars`.`id` = 1
to the query. Rails doesn’t currently provide a way to do this while retaining the ability to do the joins: the value 1
here is dependent on the instnace, and joins must construct table-referencing queries without references to instances. I do, but that post is for another time. Until then, there is another way around this, although it’s not the prettiest:
class Bar
belongs_to :foo, -> { joins(:bar).where(bars: {resource_type: 'Foo'}) }, foreign_key: 'resource_id'
belongs_to :baz, -> { joins(:bar).where(bars: {resource_type: 'Baz'}) }, foreign_
def foo(*args)
resource_type == "Foo" ? super : nil
end
def baz(*args)
resource_type == "Baz" ? super : nil
end
end
We simply override the :foo
and :baz
methods to immediately return nil if self
does not have the corerct resource_type. It’s worth noting that this use of super
only works because ActiveRecrd defines assocaition getter and setter methods in the module Bar::GeneratedAssociationMethods
that gets included into Bar
, rather than defining the methods on Bar
itself. Go Rails!
Speaking of setter methods, there’s at least one more thing we have to add. It seems that Rails does not use the assocaition scopes on create, so if we use
bar.baz = Baz.first
bar
# => #<Bar id: 1, resource_type: "Foo", resource_id: 1, foo_id: nil, deleted: false>
bar
’s resource_type is still “Foo”! To solve this, we also override the setters in Bar
, although this is beginning to feel a little hacky:
def foo=(foo)
super
self.resource = foo
end
def bar=(bar)
super
self.resource = bar
end
end
Note that there are many ways we could have done this, but this way gives us the following advantages:
super
gets called first, which correctly raises anActiveRecord::AssociationTypeMismatch
exception if we pass in the wrong type before doing anything else toself
.self.resource=()
sets the association cache for:resource
for free while also settingself.resource_type
, for a sligght usability advantage over simply usingself.resource_type=()
Lastly, there are other methods also defined on assocaitions that we would need to override. Namely, build_foo
, create_foo
, and create_foo!
. Rails is ponitferous. Let’s ust metaprogramming to make this a little more manageable:
class Bar < ActiveRecord::Base
belongs_to :resource, polymorphic: true
belongs_to :foo, ->{joins(:bars).where bars: {resource_type: "Foo"}}, foreign_key: :resource_id
belongs_to :baz, ->{joins(:bars).where bars: {resource_type: "Baz"}}, foreign_key: :resource_id
def foo(*args)
resource_type == "Foo" ? super : nil
end
def baz(*args)
resource_type == "Baz" ? super : nil
end
[:foo, :baz].each do |resource_name|
["#{resource_name}=", "build_#{resource_name}", "create_#{resource_name}", "create_#{resource_name}!"].each do |method|
define_method(method) do |resource|
typed_resource = super(resource)
self.resource = typed_resource
end
end
end
end
A little heavy. Not speaking softly anymore. What can I say, Rails is a bear.