Search code examples
ruby-on-railsactiverecordmigrationdata-migration

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?


Solution

  • 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!