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:
supergets called first, which correctly raises anActiveRecord::AssociationTypeMismatchexception if we pass in the wrong type before doing anything else toself.self.resource=()sets the association cache for:resourcefor 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.