Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to migrate a existing one-to-many relationship to many-to-many in Rails and ActiveRecord

I have a model A,

Class A < ActiveRecord::Base
    has_many: names, class_name: 'B'

and a model B

class B < ActiveRecord::Base
    belongs to :A

and there are already a bunch of data in database.

How do I write a migration to migrate them from one-to-many to many-to-many relationship? I prefer to use

has_many: through

if possible.

It's not hard to write the db migration, but what do I do to migrate the data in it?

like image 623
dalef Avatar asked Jul 12 '14 19:07

dalef


1 Answers

This scenario comes up quite often in Rails projects and I'm surprised there still aren't a lot of how-tos out there as its a straightforward data evolution but requires some delicacy when dealing with already deployed systems.

I'm not sure if you're interested in polymorphic behavior for the many-to-many but I'm throwing that in as I find it useful for many many-to-many scenarios (pun intended! :-).

I had this before I started:

class Tag < ActiveRecord::Base
  has_many :posts, inverse_of: :tag

class Post < ActiveRecord::Base
  belongs_to :tag, inverse_of: :posts

I know, I know, why only one Tag for a Post? Turns out, I wanted my Posts to have multiple Tags after all. And then I thought, wait a minute, I want other things to have Tags as well, such as some kind of Thing.

You could use :has_and_belongs_to_many for each of Posts-Tags and Things-Tags but then that makes for 2 join tables and we'll probably want to Tag more entities as they get added right? The has_many :through is a great option here for one side of our associations and avoids having multiple join tables.

We are going to do this in 2 STEPS involving 2 deploys:

Step 1 - No change to existing associations. A new Taggable model/migration that will be polymorphic with respect to Posts and Things. DEPLOY.

Step 2 - Update associations. New migration to drop the old :tag_id foreign_key from Posts. DEPLOY.

The two steps are necessary to be able to execute your migration in Step 1 using your previous association definitions, otherwise your new associations will not work.

I think two steps is the simplest approach, recommended if your traffic is low enough that the risk of additional Tags being created on Posts/Things in between the two steps is low enough. If your traffic is very high, you can combine these two steps into one but you'll need to use different association names and then go back to delete the old unused ones after a working rollout. I'll leave the 1 step approach as an exercise for the reader :-)

Step 1

Create a model migration for a new polymorphic join table.

rails g model Taggable tag_id:integer tagged_id:integer tagged_type:string --timestamps=false

Edit the resulting migration to revert to using #up and #down (instead of #change) and add the data migration:

class CreateTaggables < ActiveRecord::Migration
  def up
    create_table :taggables do |t|
      t.integer :tag_id
      t.integer :tagged_id
      t.string :tagged_type
    end

    # we pull Posts here as they have the foreign_key to tags...
    Posts.all.each do |p|
      Taggable.create(tag_id: p.tag_id, tagged_id: p.id, tagged_type: "Post")
    end
  end

  def down
    drop_table :taggables
  end
end

Edit your new model:

class Taggable < ActiveRecord::Base
  belongs_to :tag
  belongs_to :tagged, polymorphic: true
end

At this point, DEPLOY your new model and migration. Great.

Step 2

Now we're going to update our class definitions:

class Tag < ActiveRecord::Base
  has_many :taggables
  has_many :posts, through: :taggables, source: :tagged, source_type: "Post"
  has_many :things, through: :taggables, source: :tagged, source_type: "Thing"

class Post < ActiveRecord::Base
  has_and_belongs_to_many :tags, join_table: 'taggables', foreign_key: :tagged_id

class Thing < ActiveRecord::Base
  has_and_belongs_to_many :tags, join_table: 'taggables', foreign_key: :tagged_id

You should be able to add dependent: :destroy on has_many :posts and has_many :things as :tag is a belongs_to on Taggable.

Don't forget to drop your old foreign_key:

class RemoveTagIdFromPosts < ActiveRecord::Migration
  def up
    remove_column :posts, :tag_id
  end

  def down
    add_column :posts, :tag_id, :integer
  end
end

Update your specs!

DEPLOY!

like image 110
agranov Avatar answered Oct 15 '22 12:10

agranov