How To Migrate An Entire Rails App From UUID To Integer Primary Keys

Posted by Weston Ganger

If your like me and have inherited a Rails app that is currently using UUID’s but now its causing size or speed problems with your app. So its time to convert back to integer / bigint primary keys, however it is not trivial to do this because all associations and references must be kept intact.

I am maintaining a repo with a test app at https://github.com/westonganger/rails_uuid_to_integer_primary_keys

Here is a migration that is capable of handling this:

Pre-Requsuites:

  • Models must inherit from ApplicationRecord or they will not be updated

class ChangeUuidToInteger < ActiveRecord::Migration::Current
  def change
    ### LOAD ALL MODELS for `.subclasses` method
    Dir.glob(Rails.root.join("app/models/*.rb")).each{|f| require(f) }
    
    id_map = {}
    
    ApplicationRecord.subclasses.each do |outer_klass|
      outer_klass.reset_column_information
    
      if outer_klass.column_for_attribute(outer_klass.primary_key).type == :uuid
        case outer_klass.connection.adapter_name
        when "Mysql2"
          execute "ALTER TABLE #{outer_klass.table_name} DROP PRIMARY KEY;"
        else
          result = outer_klass.connection.execute("
            SELECT ('ALTER TABLE ' || table_schema || '.' || table_name || ' DROP CONSTRAINT ' || constraint_name) as my_query
            FROM information_schema.table_constraints
            WHERE table_name = '#{outer_klass.table_name}' AND constraint_type = 'PRIMARY KEY';")
              
          sql_drop_constraint_command = result.values[0].first
    
          execute(sql_drop_constraint_command)
        end
    
        rename_column outer_klass.table_name, outer_klass.primary_key, "tmp_old_#{outer_klass.primary_key}"
    
        add_column outer_klass.table_name, outer_klass.primary_key, outer_klass.connection.native_database_types[:primary_key]
    
        outer_klass.reset_column_information
    
        records = outer_klass.all
    
        if outer_klass.column_names.include?("created_at")
          records = records.reorder(created_at: :asc)
        end
    
        id_map[outer_klass] = {}
    
        records.each_with_index do |record, i|
          old_id = record.send("tmp_old_#{outer_klass.primary_key}")
    
          if record.send(outer_klass.primary_key).nil?
            new_id = i+1
            record.update_columns(outer_klass.primary_key => new_id)
          else
            new_id = record.send(outer_klass.primary_key)
          end
    
          id_map[outer_klass][old_id] = new_id
        end
    
        remove_column outer_klass.table_name, "tmp_old_#{outer_klass.primary_key}"
    
        outer_klass.reset_column_information
      end
    end
    
    ApplicationRecord.subclasses.each do |inner_klass|
      inner_klass.reflect_on_all_associations(:belongs_to).each do |reflection|
        if inner_klass.column_for_attribute(reflection.foreign_key).type == :uuid
          if reflection.polymorphic?
            ### POLYMORPHIC BELONGS TO
            
            #null_constraint = inner_klass.columns.find{|x| x.name == reflection.foreign_key }.null
            if inner_klass.connection.index_exists?(inner_klass.table_name, reflection.foreign_key)
              remove_index inner_klass.table_name, reflection.foreign_key
            end
            rename_column inner_klass.table_name, reflection.foreign_key, "tmp_old_#{reflection.foreign_key}"
            add_column inner_klass.table_name, reflection.foreign_key, :bigint#, null: null_constraint
            add_index inner_klass.table_name, reflection.foreign_key
    
            inner_klass.reset_column_information
            
            id_map.each do |outer_klass, inner_id_map|
              records = inner_klass
                .where("#{inner_klass.table_name}.tmp_old_#{reflection.foreign_key} IS NOT NULL")
                .where("#{reflection.foreign_type}" => outer_klass.name)
    
              records.each do |record|
                old_id = record.send("tmp_old_#{reflection.foreign_key}")
    
                if old_id
                  new_id = inner_id_map[old_id]
    
                  if new_id
                    ### First Update Column ID Value
                    record.update_columns(reflection.foreign_key => new_id)
                  else
                    # Orphan record, we just clear the value
                    record.update_columns(reflection.foreign_key => nil)
                  end
                end
              end
            end
    
            ### Then Change Column Type
            remove_column inner_klass.table_name, "tmp_old_#{reflection.foreign_key}"
    
            inner_klass.reset_column_information
    
          elsif id_map[reflection.klass]
            ### DIRECT BELONGS TO
            
            inner_id_map = id_map[reflection.klass]
    
            #null_constraint = inner_klass.columns.find{|x| x.name == reflection.foreign_key }.null
            if inner_klass.connection.index_exists?(inner_klass.table_name, reflection.foreign_key)
              remove_index inner_klass.table_name, reflection.foreign_key
            end
            rename_column inner_klass.table_name, reflection.foreign_key, "tmp_old_#{reflection.foreign_key}"
            add_column inner_klass.table_name, reflection.foreign_key, :bigint#, null: null_constraint
            add_index inner_klass.table_name, reflection.foreign_key
    
            inner_klass.reset_column_information
    
            records = inner_klass.where("#{inner_klass.table_name}.tmp_old_#{reflection.foreign_key} IS NOT NULL")
    
            records.each do |record|
              old_id = record.send("tmp_old_#{reflection.foreign_key}")
    
              if old_id
                new_id = inner_id_map[old_id]
    
                if new_id
                  ### First Update Column ID Value
                  record.update_columns(reflection.foreign_key => new_id)
                else
                  # Orphan record, we just clear the value
                  record.update_columns(reflection.foreign_key => nil)
                end
              end
            end
    
            ### Then Change Column Type
            remove_column inner_klass.table_name, "tmp_old_#{reflection.foreign_key}"
    
            inner_klass.reset_column_information
          end
        end
      end
    
      inner_klass.reflect_on_all_associations(:has_and_belongs_to_many).each do |reflection|
        if id_map[reflection.klass]
          inner_id_map = id_map[reflection.klass]
    
          #null_constraint = join_klass.columns.find{|x| x.name == reflection.foreign_key }.null
          if inner_klass.connection.index_exists?(reflection.join_table, reflection.association_foreign_key)
            remove_index reflect.join_table, reflection.association_foreign_key
          end
          rename_column reflect.join_table, reflection.association_foreign_key, "tmp_old_#{reflection.association_foreign_key}"
          add_column reflect.join_table, reflection.association_foreign_key, :bigint
          add_index reflect.join_table, reflection.association_foreign_key
    
          inner_id_map.each do |old_id, new_id|
            if new_id
              ### First Update Column ID Value
              execute "UPDATE #{reflection.join_table} SET #{reflection.association_foreign_key} = '#{new_id}' WHERE tmp_old_#{reflection.association_foreign_key} = '#{old_id}'"
            end
          end
    
          execute "DELETE FROM #{reflection.join_table} WHERE tmp_old_#{reflection.association_foreign_key} NOT IN ('#{inner_id_map.values.join("','")}')"
    
          remove_column reflection.join_table, "tmp_old_#{reflection.association_foreign_key}"
    
          #join_klass.reset_column_information
        end
      end
    end

  end
end

Article Topic:Software Development - Rails

Date:February 11, 2021