Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save dbreunig/bea1fb8aea4e7536ae72c2abe2e93d77 to your computer and use it in GitHub Desktop.
Save dbreunig/bea1fb8aea4e7536ae72c2abe2e93d77 to your computer and use it in GitHub Desktop.
Updating an Existing ActiveRecord Association to Polymorphic

Updating an Existing ActiveRecord Association to Polymorphic

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:

  1. Adding a :user_type column of string type.
  2. Add a new index using the :user_id and :user_type columns.
  3. 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!)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment