Rails bug with polymorphic associations
In general, I don’t like using polymorphic associations, because of inability to have a database-level integrity control and trickier eager loading. However, sometimes they look like a natural choice for certain cases in Ruby on Rails applications.
Recently, I faced a problem, which turned out to be a bug in how Rails handles negations with polymorphic associations. It took me some time to figure out what exactly was wrong, so I decided to share my experience here for future reference (mostly for myself).
Demo project
I’ve created a simple new Rails 5 application to demonstrate it (here). I’ve borrowed models schema from rails guides and wrote some tests which should help me to explain what the problem is about. In our simple app, we have 3 models (Picture, Employee and Product):
# app/models/picture.rb
class Picture < ActiveRecord::Base
belongs_to :imageable, polymorphic: true
end
# app/models/employee.rb
class Employee < ActiveRecord::Base
has_many :pictures, as: :imageable
end
# app/models/product.rb
class Product < ActiveRecord::Base
has_many :pictures, as: :imageable
end
Picture has a polymorphic association, which means that it has a property “imageable” which may hold a reference to either Employee or Product.
Here is how the migrations look like:
class CreateEmployees < ActiveRecord::Migration[5.0]
def change
create_table :employees do |t|
t.string :name
t.timestamps
end
end
end
class CreateProducts < ActiveRecord::Migration[5.0]
def change
create_table :products do |t|
t.string :name
t.timestamps
end
end
end
class CreatePictures < ActiveRecord::Migration[5.0]
def change
create_table :pictures do |t|
t.string :name
t.references :imageable, polymorphic: true, index: true
t.timestamps null: false
end
end
end
Reproducing a problem in a test
Everything looks pretty straightforward so far. Now to see the problem, let’s write a test (the source code is here):
require 'test_helper'
class PictureTest < ActiveSupport::TestCase
def setup
# Create 2 users: Ivan and Michael
@ivan = Employee.create(name: 'Ivan')
@michael = Employee.create(name: 'Michael')
# Create 2 products: OS X and Windows
@osx = Product.create(name: 'OS X')
@windows = Product.create(name: 'Windows')
# Create a picture, which has a reference to employee Ivan
@ivan_avatar = Picture
.create(name: "Ivan's avatar", imageable: @ivan)
# Create a picture, which has a reference to employee Michael
@michael_avatar = Picture
.create(name: "Michael's avatar", imageable: @michael)
# Create a picture, which has a reference to product OS X
@osx_logo = Picture
.create(name: 'OS X Logo', imageable: @osx)
# Create a picture, which has a reference to product Windows
@windows_logo = Picture
.create(name: 'Windows Logo', imageable: @windows)
end
test 'polymorphic association negation' do
images = Picture.where.not(imageable: @ivan).order(:name)
# all images except Ivan's avatar should be found here
assert_equal images, [@michael_avatar, @osx_logo, @windows_logo]
end
end
So, what exactly is going on here? I’ve added comments to almost every line, so it should be clear enough what this test is supposed to test. We have 2 users (Ivan and Michael) and 2 products (OS X and Windows). Every user has an avatar (which is represented by the Picture objects and holds reference to user). Every product has a logo (which is also represented by Picture object and holds a reference to product).
In the test itself, we are making query:
images = Picture.where.not(imageable: @ivan).order(:name)
We can read it as “Find all pictures which don’t reference to Ivan”. So our expectation is:
assert_equal images, [@michael_avatar, @osx_logo, @windows_logo]
which means that we expect all the images to be found except one representing Ivan’s avatar.
But when we run this test, we get this error:
Failure:
PictureTest#test_polymorphic_association_negation [/Users/ivan/code/rails_polymorphic_bug/test/models/picture_test.rb:22]:
--— expected
+++ actual
@@ -1 +1 @@
-#<ActiveRecord::Relation [#<Picture id: 4, name: "Windows Logo", imageable_type: "Product", imageable_id: 2, created_at: "2016–04–24 18:45:51", updated_at: "2016–04–24 18:45:51">]>
+[#<Picture id: 2, name: "Michael's avatar", imageable_type: "Employee", imageable_id: 2, created_at: "2016–04–24 18:45:51", updated_at: "2016–04–24 18:45:51">, #<Picture id: 3, name: "OS X Logo", imageable_type: "Product", imageable_id: 1, created_at: "2016–04–24 18:45:51", updated_at: "2016–04–24 18:45:51">, #<Picture id: 4, name: "Windows Logo", imageable_type: "Product", imageable_id: 2, created_at: "2016–04–24 18:45:51", updated_at: "2016–04–24 18:45:51">]
It looks like only “Windows Logo” was returned! But where are “OS X Logo” and “Michael’s avatar”?
Diagnosing problem
images = Picture.where.not(imageable: @ivan).order(:name)
puts images.to_sql
To understand, why only one picture was returned (while we were expecting 3), we can check the SQL query generated to perform the search:
Here is what we get (with some formatting applied):
SELECT "pictures".*
FROM "pictures"
WHERE ("pictures"."imageable_type" != 'Employee')
AND ("pictures"."imageable_id" != 1)
ORDER BY "pictures"."name" ASC
As we can see, this query only fetches pictures where both conditions are met:
- imageable_type is not “Employee”
- imageable_id is not “1”
So pictures referencing any object with type ‘Employee’, as well as any picture referencing any object with id’1’, will not match!
Thus, Michael’s avatar was not found, because it holds a reference to Michael, which has type ‘Employee’. And ‘OS X Logo’ was not found, because it holds a reference to ‘OS X’, which has id ‘1’. So only ‘Windows Logo’ was found, because it references ‘Product’ with id ‘2’.
It clearly looks like a bug. There is an issue on Rails’ issue tracker and even a pull request which fixes it.
Solution
Today (as of April 24th 2016), this bug is still not fixed, despite the fact that the pull request to fix it was created in September 2014. So until it’s merged (if ever), we need to solve that somehow.
The most obvious solution that I came up with was to just build the query manually, like this:
images = Picture
.where("imageable_type <> ? or imageable_id <> ?",
@ivan.class.name, @ivan.id)
.order(:name)
Which will just replace ‘AND’ condition in the SQL query to ‘OR’.