There's plenty of documentation out there describing how to create a polymorphic association in Rails and Active Record. But not much about updating an existing association.
Say you have the following model:
class Post < ApplicationRecord
belongs_to :user
end
Which was generated with the following migration:
class CreatePosts < ActiveRecord::Migration[7.0]
def change
create_table :posts do |t|
t.belongs_to :user, null: false, foreign_key: true
t.string :title, null: false
t.text :body, null: false
t.timestamps
end
end
end
You're have a User
who can create a Post
. But one day, we decide we want to create a GuestUser
model. This would allow somebody to author and manage their posts without creating a full account.
Sure, we could add a flag to your Users
table to do this, but you might have some other fields or context making this untenable. Instead, we'd like two separate classes and a polymorphic belongs_to :user
association on Post
. How can we modify the user
relationship?
Looking at the Rails docs, to create a polymorphic association we need a migration like this:
class CreatePictures < ActiveRecord::Migration[7.1]
def change
create_table :pictures do |t|
t.string :name
t.bigint :imageable_id
t.string :imageable_type
t.timestamps
end
add_index :pictures, [:imageable_type, :imageable_id]
end
end
You could also shortcut the :imageable_id
, :imageable_type
, and the associated index with t.references :imageable, polymorphic: true
. This means we need to modify our Posts
table by:
- Adding a
:user_type
column ofstring
type. - Add a new index using the
:user_id
and:user_type
columns. - Remove our old index, which just used
user_id
.
In between steps 2 and 3 we're going to have to manually add a user_type
value to all existing Posts
. We could do this...
class MakePostUserPolymorphic < ActiveRecord::Migration[7.0]
def up
add_column :posts, :user_type, :string
add_index :posts, [:user_id, :user_type]
Post.all.each do |post|
post.update(user_type: "User")
end
remove_index :posts, :user_id
end
def down
remove_index :posts, [:user_id, :user_type]
remove_column :posts, :user_type
add_index :posts, :user_id
end
end
Run the migration, then update our Post
model so it reads:
class Post < ApplicationRecord
belongs_to :user, polymorphic: true
end
Everything will work! BUT if you push this to production it will fail. Why?
The problem is that the model code will get updated before the migration is run. When the migration is run after the polymorphic: true
flag is in place, ActiveRecord will run all sorts of validation, change updates, and hooks. Specifically, it will try to look at how you're updating the associated record and compare it to the old value...for which there is no model type string. And you'll get this error:
NoMethodError: undefined method `<' for nil:NilClass (NoMethodError)
if foreign_key_was && model_was < ActiveRecord::Base
Trying to use .save(validate: false)
will not save you.
Instead, just did down to raw SQL. Like so:
class MakePostUserPolymorphic < ActiveRecord::Migration[7.0]
def up
add_column :posts, :user_type, :string
add_index :posts, [:user_id, :user_type]
ActiveRecord::Base.connection.execute("UPDATE posts SET user_type = 'User'")
remove_index :posts, :user_id
end
def down
remove_index :posts, [:user_id, :user_type]
remove_column :posts, :user_type
add_index :posts, :user_id
end
end
Works like a charm. (And it's faster!)