├── Rakefile ├── lib └── webdack │ ├── uuid_migration.rb │ └── uuid_migration │ ├── version.rb │ ├── schema_helpers.rb │ └── helpers.rb ├── spec ├── support │ ├── models │ │ ├── city.rb │ │ ├── school.rb │ │ ├── college.rb │ │ └── student.rb │ ├── initial_data.rb │ ├── pg_database_helper.rb │ └── initial_schema.rb ├── spec_helper.rb ├── schema_helper_spec.rb ├── uuid_custom_pk_spec.rb └── uuid_migrate_helper_spec.rb ├── gemfiles ├── rails50.gemfile ├── rails51.gemfile ├── rails52.gemfile ├── rails60.gemfile ├── rails61.gemfile ├── rails70.gemfile ├── rails71.gemfile └── rails42.gemfile ├── Gemfile ├── .github ├── dependabot.yml └── workflows │ └── ubuntu.yml ├── .gitignore ├── LICENSE.txt ├── webdack-uuid_migration.gemspec └── README.md /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | -------------------------------------------------------------------------------- /lib/webdack/uuid_migration.rb: -------------------------------------------------------------------------------- 1 | require "webdack/uuid_migration/version" 2 | 3 | -------------------------------------------------------------------------------- /spec/support/models/city.rb: -------------------------------------------------------------------------------- 1 | class City < ActiveRecord::Base 2 | has_many :students 3 | end 4 | -------------------------------------------------------------------------------- /spec/support/models/school.rb: -------------------------------------------------------------------------------- 1 | class School < ActiveRecord::Base 2 | has_many :students, as: :institution 3 | end 4 | -------------------------------------------------------------------------------- /spec/support/models/college.rb: -------------------------------------------------------------------------------- 1 | class College < ActiveRecord::Base 2 | has_many :students, as: :institution 3 | end 4 | -------------------------------------------------------------------------------- /gemfiles/rails50.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "activerecord", "~>5.0.0" 4 | 5 | gemspec :path=>"../" 6 | -------------------------------------------------------------------------------- /gemfiles/rails51.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "activerecord", "~>5.1.0" 4 | 5 | gemspec :path=>"../" 6 | -------------------------------------------------------------------------------- /gemfiles/rails52.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem 'activerecord', '~>5.2.0' 4 | 5 | gemspec :path=>"../" 6 | -------------------------------------------------------------------------------- /gemfiles/rails60.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem 'activerecord', '~>6.0.0' 4 | 5 | gemspec :path=>"../" 6 | -------------------------------------------------------------------------------- /gemfiles/rails61.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem 'activerecord', '~>6.1.0' 4 | 5 | gemspec :path=>"../" 6 | -------------------------------------------------------------------------------- /gemfiles/rails70.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem 'activerecord', '~>7.0.0' 4 | 5 | gemspec :path=>"../" 6 | -------------------------------------------------------------------------------- /gemfiles/rails71.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem 'activerecord', '~>7.1.0' 4 | 5 | gemspec :path=>"../" 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in webdack-uuid_migration.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /lib/webdack/uuid_migration/version.rb: -------------------------------------------------------------------------------- 1 | # 2 | module Webdack 3 | # 4 | module UUIDMigration 5 | VERSION = "1.5.0" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/models/student.rb: -------------------------------------------------------------------------------- 1 | class Student < ActiveRecord::Base 2 | belongs_to :city, -> {where('true')} 3 | 4 | belongs_to :institution, -> {where('true')}, :polymorphic => true 5 | end 6 | -------------------------------------------------------------------------------- /gemfiles/rails42.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "activerecord", "~>4.2.0" 4 | 5 | # This version of ActiveRecrod does not support higher 6 | gem "pg", '< 1.0' 7 | 8 | gemspec :path=>"../" 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | time: "23:30" 8 | open-pull-requests-limit: 10 9 | target-branch: develop 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | .idea 7 | Gemfile.lock 8 | gemfiles/*.lock 9 | InstalledFiles 10 | _yardoc 11 | coverage 12 | doc/ 13 | lib/bundler/man 14 | pkg 15 | rdoc 16 | spec/reports 17 | test/tmp 18 | test/version_tmp 19 | tmp 20 | -------------------------------------------------------------------------------- /spec/support/initial_data.rb: -------------------------------------------------------------------------------- 1 | 2 | def populate_sample_data 3 | (0..4).each do |i| 4 | City.create(name: "City #{i}") 5 | School.create(name: "School #{i}") 6 | College.create(name: "College #{i}") 7 | end 8 | 9 | (0..49).each do |i| 10 | 11 | institution= if i.even? then 12 | School.where(name: "School #{(i/2)%5}").first 13 | else 14 | College.where(name: "College #{(i/2)%5}").first 15 | end 16 | 17 | Student.create( 18 | name: "Student #{i}", 19 | city: City.where(name: "City #{i%5}").first, 20 | institution: institution 21 | ) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | Bundler.setup(:development) 2 | 3 | require 'rspec' 4 | require 'active_record' 5 | require 'pg' 6 | require 'webdack/uuid_migration/helpers' 7 | 8 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } 9 | 10 | RSpec.configure do |c| 11 | if Gem::Version.new(ActiveRecord::VERSION::STRING) < Gem::Version.new('4.2') 12 | c.filter_run_excluding rails_4_2_or_newer: true 13 | end 14 | end 15 | 16 | ActiveRecordMigration = if ActiveRecord.version >= Gem::Version.new('5.0.0') 17 | ActiveRecord::Migration[5.0] 18 | else 19 | ActiveRecord::Migration 20 | end -------------------------------------------------------------------------------- /spec/support/pg_database_helper.rb: -------------------------------------------------------------------------------- 1 | # With thanks to http://7fff.com/2010/12/02/activerecord-dropcreate-database-run-migrations-outside-of-rails/ 2 | 3 | PG_SPEC = { 4 | :adapter => 'postgresql', 5 | :host => '127.0.0.1', 6 | :port => 5432, 7 | :database => 'webdack_uuid_migration_helper_test', 8 | :username => 'postgres', 9 | :password => 'password', 10 | :encoding => 'utf8' 11 | } 12 | 13 | def init_database 14 | # drops and create need to be performed with a connection to the 'postgres' (system) database 15 | ActiveRecord::Base.establish_connection(PG_SPEC.merge('database' => 'postgres', 'schema_search_path' => 'public')) 16 | # drop the old database (if it exists) 17 | ActiveRecord::Base.connection.drop_database PG_SPEC[:database] 18 | # create new 19 | ActiveRecord::Base.connection.create_database(PG_SPEC[:database]) 20 | ActiveRecord::Base.establish_connection(PG_SPEC) 21 | end 22 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Deepak Kumar 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /webdack-uuid_migration.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'webdack/uuid_migration/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "webdack-uuid_migration" 8 | spec.version = Webdack::UUIDMigration::VERSION 9 | spec.authors = ["Deepak Kumar"] 10 | spec.email = ["deepak@kreatio.com"] 11 | spec.summary = %q{Useful helpers to migrate Integer id columns to UUID in PostgreSql.} 12 | spec.description = %q{Useful helpers to consistently migrate Integer id columns to UUID in PostgreSql. Special support for primary keys and references.} 13 | spec.homepage = "https://github.com/kreatio-sw/webdack-uuid_migration" 14 | spec.license = 'MIT' 15 | 16 | spec.files = `git ls-files`.split($/) 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_development_dependency "bundler" 22 | spec.add_development_dependency "rake" 23 | spec.add_development_dependency "yard" 24 | spec.add_development_dependency "rspec" 25 | spec.add_development_dependency "pg", '< 2.0' 26 | spec.add_development_dependency 'gem-release' 27 | 28 | spec.add_dependency 'activerecord', '>= 4.0' 29 | end 30 | -------------------------------------------------------------------------------- /spec/support/initial_schema.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | 3 | def create_initial_schema 4 | ActiveRecord::Schema.define(version: 20140117141611) do 5 | 6 | # These are extensions that must be enabled in order to support this database 7 | enable_extension "plpgsql" 8 | 9 | create_table "cities", force: true do |t| 10 | t.string "name" 11 | t.datetime "created_at" 12 | t.datetime "updated_at" 13 | end 14 | 15 | create_table "colleges", force: true do |t| 16 | t.string "name" 17 | t.datetime "created_at" 18 | t.datetime "updated_at" 19 | end 20 | 21 | create_table "schools", force: true do |t| 22 | t.string "name" 23 | t.datetime "created_at" 24 | t.datetime "updated_at" 25 | end 26 | 27 | create_table "students", force: true do |t| 28 | t.string "name" 29 | t.integer "city_id" 30 | t.string "institution_type" 31 | t.integer "institution_id" 32 | t.datetime "created_at" 33 | t.datetime "updated_at" 34 | 35 | t.index "city_id" 36 | t.index ["institution_type", "institution_id"] 37 | end 38 | 39 | end 40 | end 41 | 42 | def create_tables_with_fk 43 | connection = ActiveRecord::Base.connection 44 | 45 | connection.create_table "dummy01", force: true do |t| 46 | t.string "name" 47 | t.references "city", index: true, foreign_key: {on_update: :cascade, on_delete: :nullify} 48 | end 49 | 50 | connection.create_table "dummy02", force: true do |t| 51 | t.string "name" 52 | t.references "city", index: true, foreign_key: {on_update: :restrict, on_delete: :restrict} 53 | end 54 | end -------------------------------------------------------------------------------- /.github/workflows/ubuntu.yml: -------------------------------------------------------------------------------- 1 | name: GitHub CI Tests 2 | 3 | on: [ push, pull_request ] 4 | 5 | jobs: 6 | test: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | include: 12 | - ruby-version: 2.5 13 | postgres: 9.6 14 | gemfile: gemfiles/rails42.gemfile 15 | - ruby-version: 2.6 16 | postgres: 10 17 | gemfile: gemfiles/rails52.gemfile 18 | - ruby-version: 2.7 19 | postgres: 12 20 | gemfile: gemfiles/rails61.gemfile 21 | - ruby-version: 3.0 22 | postgres: 14 23 | gemfile: gemfiles/rails70.gemfile 24 | - ruby-version: 3.1 25 | postgres: 16 26 | gemfile: gemfiles/rails71.gemfile 27 | 28 | # service containers to run with `postgres-job` 29 | services: 30 | # label used to access the service container 31 | postgres: 32 | # Docker Hub image 33 | image: postgres:${{ matrix.postgres }} 34 | # service environment variables 35 | # `POSTGRES_HOST` is `postgres` 36 | env: 37 | # optional (defaults to `postgres`) 38 | # POSTGRES_DB: postgres_db 39 | # required 40 | POSTGRES_PASSWORD: password 41 | # optional (defaults to `5432`) 42 | # POSTGRES_PORT: 5432 43 | # optional (defaults to `postgres`) 44 | # POSTGRES_USER: postgres_user 45 | ports: 46 | # maps tcp port 5432 on service container to the host 47 | - 5432:5432 48 | 49 | 50 | steps: 51 | - uses: actions/checkout@v3 52 | - name: Set up Ruby 53 | uses: ruby/setup-ruby@v1 54 | with: 55 | ruby-version: ${{ matrix.ruby-version }} 56 | - name: Install dependencies 57 | run: bundle install --gemfile ${{ matrix.gemfile }} 58 | - name: Run tests 59 | run: bundle exec rspec spec 60 | -------------------------------------------------------------------------------- /lib/webdack/uuid_migration/schema_helpers.rb: -------------------------------------------------------------------------------- 1 | module Webdack 2 | module UUIDMigration 3 | module SchemaHelpers 4 | def foreign_keys_into(to_table_name) 5 | to_primary_key = primary_key(to_table_name) 6 | 7 | 8 | fk_info = select_all <<-SQL 9 | SELECT t2.oid::regclass::text AS to_table, a2.attname AS primary_key, t1.relname as from_table, a1.attname AS column, c.conname AS name, c.confupdtype AS on_update, c.confdeltype AS on_delete 10 | FROM pg_constraint c 11 | JOIN pg_class t1 ON c.conrelid = t1.oid 12 | JOIN pg_class t2 ON c.confrelid = t2.oid 13 | JOIN pg_attribute a1 ON a1.attnum = c.conkey[1] AND a1.attrelid = t1.oid 14 | JOIN pg_attribute a2 ON a2.attnum = c.confkey[1] AND a2.attrelid = t2.oid 15 | JOIN pg_namespace t3 ON c.connamespace = t3.oid 16 | WHERE c.contype = 'f' 17 | AND t2.oid::regclass::text = #{quote(to_table_name)} 18 | AND a2.attname = #{quote(to_primary_key)} 19 | ORDER BY t1.relname, a1.attname 20 | SQL 21 | 22 | fk_info.map do |row| 23 | options = { 24 | to_table: row['to_table'], 25 | primary_key: row['primary_key'], 26 | from_table: row['from_table'], 27 | column: row['column'], 28 | name: row['name'] 29 | } 30 | 31 | options[:on_delete] = extract_foreign_key_action(row['on_delete']) 32 | options[:on_update] = extract_foreign_key_action(row['on_update']) 33 | 34 | options 35 | end 36 | end 37 | 38 | def extract_foreign_key_action(specifier) 39 | case specifier 40 | when "c"; :cascade 41 | when "n"; :nullify 42 | when "r"; :restrict 43 | end 44 | end 45 | 46 | def drop_foreign_keys(foreign_keys) 47 | foreign_keys.each do |fk_key_spec| 48 | foreign_key_spec = fk_key_spec.dup 49 | from_table = foreign_key_spec.delete(:from_table) 50 | remove_foreign_key from_table, name: foreign_key_spec[:name] 51 | end 52 | end 53 | 54 | def create_foreign_keys(foreign_keys) 55 | foreign_keys.each do |fk_key_spec| 56 | foreign_key_spec = fk_key_spec.dup 57 | from_table = foreign_key_spec.delete(:from_table) 58 | to_table = foreign_key_spec.delete(:to_table) 59 | add_foreign_key from_table, to_table, **foreign_key_spec 60 | end 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/schema_helper_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | ActiveRecord::ConnectionAdapters::AbstractAdapter.class_eval do 4 | include Webdack::UUIDMigration::SchemaHelpers 5 | end 6 | 7 | describe Webdack::UUIDMigration::SchemaHelpers, rails_4_2_or_newer: true do 8 | def initial_setup 9 | init_database 10 | create_initial_schema 11 | 12 | # Create 2 more tables similar to the way new version of Rails will do 13 | create_tables_with_fk 14 | end 15 | 16 | before(:each) do 17 | initial_setup 18 | 19 | @connection = ActiveRecord::Base.connection 20 | 21 | # Create one more foreign key constraints 22 | @connection.add_foreign_key :students, :cities 23 | end 24 | 25 | it 'should get all foreign keys into a table' do 26 | foreign_keys_into = @connection.foreign_keys_into(:cities) 27 | 28 | # Remove column :name from the FK info 29 | foreign_keys_into = foreign_keys_into.map { |i| i.delete(:name); i } 30 | 31 | expect(foreign_keys_into).to eq([{:to_table => "cities", 32 | :primary_key => "id", 33 | :from_table => "dummy01", 34 | :column => "city_id", 35 | :on_delete => :nullify, 36 | :on_update => :cascade}, 37 | {:to_table => "cities", 38 | :primary_key => "id", 39 | :from_table => "dummy02", 40 | :column => "city_id", 41 | :on_delete => :restrict, 42 | :on_update => :restrict}, 43 | {:to_table => "cities", 44 | :primary_key => "id", 45 | :from_table => "students", 46 | :column => "city_id", 47 | :on_delete => nil, 48 | :on_update => nil}]) 49 | end 50 | 51 | it 'should drop all foreign keys into a table' do 52 | fk_specs = @connection.foreign_keys_into(:cities) 53 | 54 | @connection.drop_foreign_keys(fk_specs) 55 | 56 | expect(@connection.foreign_keys_into(:cities)).to eq([]) 57 | end 58 | 59 | it 'should drop all recreate all foreign keys into a table' do 60 | fk_specs = @connection.foreign_keys_into(:cities) 61 | 62 | @connection.drop_foreign_keys(fk_specs) 63 | @connection.create_foreign_keys(fk_specs) 64 | 65 | expect(@connection.foreign_keys_into(:cities)).to eq(fk_specs) 66 | end 67 | 68 | end 69 | -------------------------------------------------------------------------------- /spec/uuid_custom_pk_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | class MigrationBase < ActiveRecordMigration 4 | def change 5 | create_table :states, primary_key: :stateid do |t| 6 | t.string :name 7 | end 8 | 9 | enable_extension 'pgcrypto' 10 | end 11 | end 12 | 13 | class Migration01 < ActiveRecordMigration 14 | def change 15 | reversible do |dir| 16 | dir.up do 17 | primary_key_to_uuid :states 18 | end 19 | 20 | dir.down do 21 | raise ActiveRecord::IrreversibleMigration 22 | end 23 | end 24 | end 25 | end 26 | 27 | class Migration02 < ActiveRecordMigration 28 | def change 29 | reversible do |dir| 30 | dir.up do 31 | primary_key_to_uuid :states, primary_key: :stateid 32 | end 33 | 34 | dir.down do 35 | raise ActiveRecord::IrreversibleMigration 36 | end 37 | end 38 | end 39 | end 40 | 41 | class Migration03 < ActiveRecordMigration 42 | def change 43 | reversible do |dir| 44 | dir.up do 45 | primary_key_to_uuid :states, default: 'gen_random_uuid()' 46 | end 47 | 48 | dir.down do 49 | raise ActiveRecord::IrreversibleMigration 50 | end 51 | end 52 | end 53 | end 54 | 55 | class State < ActiveRecord::Base 56 | end 57 | 58 | describe Webdack::UUIDMigration::Helpers do 59 | def initial_setup 60 | init_database 61 | 62 | MigrationBase.migrate(:up) 63 | 64 | (0..9).each do |i| 65 | State.create(name: "State #{i}") 66 | end 67 | end 68 | 69 | def reset_columns_data 70 | [State].each{|klass| klass.reset_column_information} 71 | end 72 | 73 | def key_relationships 74 | [ 75 | State.order(:name).map { |s| [s.name] } 76 | ] 77 | end 78 | 79 | before(:each) do 80 | initial_setup 81 | end 82 | 83 | it 'should migrate table with custom primary_key' do 84 | expect { 85 | Migration01.migrate(:up) 86 | reset_columns_data 87 | }.to_not change { 88 | key_relationships 89 | } 90 | 91 | expect(State.connection.primary_key(:states)).to eq 'stateid' 92 | end 93 | 94 | it 'should honour primary_key with explicit hint' do 95 | expect { 96 | Migration02.migrate(:up) 97 | reset_columns_data 98 | }.to_not change { 99 | key_relationships 100 | } 101 | 102 | expect(State.connection.primary_key(:states)).to eq 'stateid' 103 | end 104 | 105 | it 'should honour default' do 106 | expect { 107 | Migration03.migrate(:up) 108 | reset_columns_data 109 | }.to_not change { 110 | key_relationships 111 | } 112 | 113 | default_function = State.connection.columns(:states).find { |c| c.name == 'stateid' }.default_function 114 | expect(default_function).to eq 'gen_random_uuid()' 115 | end 116 | 117 | end 118 | -------------------------------------------------------------------------------- /lib/webdack/uuid_migration/helpers.rb: -------------------------------------------------------------------------------- 1 | require_relative 'schema_helpers' 2 | 3 | module Webdack 4 | module UUIDMigration 5 | module Helpers 6 | include SchemaHelpers 7 | 8 | # Converts primary key from Serial Integer to UUID, migrates all data by left padding with 0's 9 | # sets gen_random_uuid() as default for the column 10 | # 11 | # @param table [Symbol] 12 | # @param options [hash] 13 | # @option options [Symbol] :primary_key if not supplied queries the schema (should work most of the times) 14 | # @option options [String] :default mechanism to generate UUID for new records, default gen_random_uuid(), 15 | # which is Rails 4.0.0 default as well 16 | # @option options [String] :seed used as namespace to generate UUIDv5 from the existing ID 17 | # @return [none] 18 | def primary_key_to_uuid(table, options = {}) 19 | default = options[:default] || 'gen_random_uuid()' 20 | seed = options[:seed] 21 | 22 | column = connection.primary_key(table) 23 | 24 | execute %Q{ALTER TABLE #{table} 25 | ALTER COLUMN #{column} DROP DEFAULT, 26 | ALTER COLUMN #{column} SET DATA TYPE UUID USING (#{to_uuid_pg(column, seed)}), 27 | ALTER COLUMN #{column} SET DEFAULT #{default}} 28 | 29 | execute %Q{DROP SEQUENCE IF EXISTS #{table}_#{column}_seq} rescue nil 30 | end 31 | 32 | # Converts a column to UUID, migrates all data by left padding with 0's 33 | # 34 | # @param table [Symbol] 35 | # @param column [Symbol] 36 | # @param seed [String] 37 | # 38 | # @return [none] 39 | def column_to_uuid(table, column, seed: nil) 40 | execute %Q{ALTER TABLE #{table} 41 | ALTER COLUMN #{column} SET DATA TYPE UUID USING (#{to_uuid_pg(column, seed)})} 42 | end 43 | 44 | # Converts columns to UUID, migrates all data by left padding with 0's 45 | # 46 | # @param table [Symbol] 47 | # @param columns 48 | # @param seed [String] 49 | # 50 | # @return [none] 51 | def columns_to_uuid(table, *columns, seed: nil) 52 | columns.each do |column| 53 | column_to_uuid(table, column, seed: seed) 54 | end 55 | end 56 | 57 | # Convert an Integer to UUID formatted string by left padding with 0's 58 | # 59 | # @param num [Integer] 60 | # @return [String] 61 | def int_to_uuid(num) 62 | '00000000-0000-0000-0000-%012d' % num.to_i 63 | end 64 | 65 | # Convert data values to UUID format for polymorphic associations. Useful when only few 66 | # of associated entities have switched to UUID primary keys. Before calling this ensure that 67 | # the corresponding column_id has been changed to :string (VARCHAR(36) or larger) 68 | # 69 | # See Polymorphic References in {file:README.md} 70 | # 71 | # @param table[Symbol] 72 | # @param column [Symbol] it will change data in corresponding _id 73 | # @param entities [String] data referring these entities will be converted 74 | # @param seed [String] 75 | 76 | def polymorphic_column_data_for_uuid(table, column, *entities, seed: nil) 77 | list_of_entities = entities.map { |e| "'#{e}'" }.join(', ') 78 | execute %Q{ 79 | UPDATE #{table} SET #{column}_id= #{to_uuid_pg("#{column}_id", seed)} 80 | WHERE #{column}_type in (#{list_of_entities}) 81 | } 82 | end 83 | 84 | # Convert primary key of a table and all referring columns to UUID. Useful if migrations were generated 85 | # with newer version of Rails that automatically creates foreign key constraints in the database. 86 | # 87 | # Internally it will query the database to find all tables & columns referring to this primary key as foreign keys 88 | # and do the following: 89 | # 90 | # - Drop all foreign key constraints referring to this primary key 91 | # - Convert the primary key to UUID 92 | # - Convert all referring columns to UUID 93 | # - Restore all foreign keys 94 | # 95 | # @param table[Symbol] 96 | # @param seed [String] 97 | 98 | # @note Works only with Rails 4.2 or newer 99 | def primary_key_and_all_references_to_uuid(table, seed: nil) 100 | fk_specs = foreign_keys_into(table) 101 | 102 | drop_foreign_keys(fk_specs) 103 | 104 | primary_key_to_uuid(table, seed: seed) 105 | 106 | fk_specs.each do |fk_spec| 107 | columns_to_uuid fk_spec[:from_table], fk_spec[:column], seed: seed 108 | end 109 | 110 | create_foreign_keys(fk_specs.deep_dup) 111 | end 112 | 113 | private 114 | 115 | # Prepare a fragment that can be used in SQL statements that converts teh data value 116 | # from integer, string, or UUID to valid UUID string as per Postgres guidelines 117 | # 118 | # @param column [Symbol] 119 | # @param seed [String] 120 | 121 | # @return [String] 122 | def to_uuid_pg(column, seed) 123 | if seed 124 | "uuid_generate_v5('#{seed}'::uuid, #{column}::text)" 125 | else 126 | "uuid(lpad(replace(text(#{column}),'-',''), 32, '0'))" 127 | end 128 | end 129 | end 130 | end 131 | end 132 | 133 | ActiveRecord::Migration.class_eval do 134 | include Webdack::UUIDMigration::Helpers 135 | end 136 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Webdack::UuidMigration 2 | 3 | [![GitHub CI Tests](https://github.com/kreatio-sw/webdack-uuid_migration/actions/workflows/ubuntu.yml/badge.svg)](https://github.com/kreatio-sw/webdack-uuid_migration/actions/workflows/ubuntu.yml) 4 | 5 | **This project is actively maintained. Please report issues and/or create 6 | pull requests if you face any issues.** 7 | 8 | There are plenty of tutorials around the web on how to use UUIDs with Rails. 9 | However, there is no reliable tutorial to help convert an in-production Rails application 10 | from Integer ids to UUIDs. 11 | 12 | This gem has helper methods to convert Integer columns to UUIDs during migrations. 13 | It supports migrating primary key columns, relations, and polymorphic relations. 14 | 15 | It is designed to be fast and is suitable for in-place migration of schema and data. 16 | It has been used in production since 2014. 17 | 18 | This only supports PostgreSQL. 19 | 20 | ## Documentation 21 | 22 | http://www.rubydoc.info/gems/webdack-uuid_migration (The link may occasionally not work). 23 | 24 | ## Installation 25 | 26 | Add this line to your application's Gemfile: 27 | 28 | ```ruby 29 | gem 'webdack-uuid_migration' 30 | ``` 31 | 32 | And then execute: 33 | 34 | ```bash 35 | $ bundle 36 | ``` 37 | 38 | Or install it yourself as: 39 | 40 | ```bash 41 | $ gem install webdack-uuid_migration 42 | ``` 43 | 44 | This gem is needed only during database migrations. 45 | Once the database has been migrated in all environments, 46 | this gem can safely be removed from your applications Gemfile. 47 | 48 | ## Usage 49 | 50 | - Put `require 'webdack/uuid_migration/helpers'` in your migration file. 51 | - Enable `'pgcrypto'` directly in Postgres database or by adding `enable_extension 'pgcrypto'` to your migration. 52 | If you want to generate random UUIDs, enable `uuid-ossp` as well. 53 | - Use methods from {Webdack::UUIDMigration::Helpers} as appropriate. 54 | 55 | Example: 56 | 57 | ```ruby 58 | # You must explicitly require it in your migration file 59 | require 'webdack/uuid_migration/helpers' 60 | 61 | class UuidMigration < ActiveRecord::Migration 62 | def change 63 | reversible do |dir| 64 | dir.up do 65 | # Good idea to do the following, needs superuser rights in the database 66 | # Alternatively the extension needs to be manually enabled in the RDBMS 67 | enable_extension 'pgcrypto' 68 | 69 | primary_key_to_uuid :students 70 | 71 | primary_key_to_uuid :cities 72 | primary_key_to_uuid :sections 73 | columns_to_uuid :students, :city_id, :section_id 74 | end 75 | 76 | dir.down do 77 | raise ActiveRecord::IrreversibleMigration 78 | end 79 | end 80 | end 81 | end 82 | ``` 83 | 84 | By default, integer values are converted to UUID by padding 0's to the left. 85 | This makes it possible to retrieve old id in future. See **Generating random UUIDs** below to 86 | convert integer into random UUIDs using a seed. 87 | 88 | See {Webdack::UUIDMigration::Helpers} for more details. {Webdack::UUIDMigration::Helpers} is mixed 89 | into {ActiveRecord::Migration}, so that all methods can directly be used within migrations. 90 | 91 | ### Schema with Foreign Key References 92 | 93 | Please see [https://github.com/kreatio-sw/webdack-uuid_migration/issues/4] 94 | 95 | This function will only work with Rails 4.2 or newer. 96 | 97 | To update a primary key and all columns referencing it please use 98 | {Webdack::UUIDMigration::Helpers#primary_key_and_all_references_to_uuid}. For example: 99 | 100 | ```ruby 101 | class MigrateWithFk < ActiveRecord::Migration 102 | def change 103 | reversible do |dir| 104 | dir.up do 105 | enable_extension 'pgcrypto' 106 | 107 | primary_key_and_all_references_to_uuid :cities 108 | end 109 | 110 | dir.down do 111 | raise ActiveRecord::IrreversibleMigration 112 | end 113 | end 114 | end 115 | end 116 | ``` 117 | 118 | Internally it will query the database to find all tables & columns referring to this primary key as foreign keys 119 | and do the following: 120 | 121 | - Drop all foreign key constraints referring to this primary key 122 | - Convert the primary key to UUID 123 | - Convert all referring columns to UUID 124 | - Restore all foreign keys 125 | 126 | ### Polymorphic references 127 | 128 | Migrating Polymorphic references may get tricky if not all the participating entities are getting migrated to 129 | UUID primary keys. If only some of the referenced entities are getting migrated to use UUID primary keys please use the 130 | following steps: 131 | 132 | - Change the corresponding _id to String type (at least VARCHAR(36)). 133 | - Call `polymorphic_column_data_for_uuid :table, :column, 'Entity1', 'Entity2', ...` 134 | - Note that :column in is without the _id. 135 | - See {Webdack::UUIDMigration::Helpers#polymorphic_column_data_for_uuid} 136 | - When all remaining references also gets migrated to UUID primary keys, call `columns_to_uuid :table, :column_id` 137 | 138 | Example: 139 | 140 | ```ruby 141 | # Student -- belongs_to :institution, :polymorphic => true 142 | # An institution is either a School or a College 143 | # College is migrated to use UUID as primary key 144 | # School uses Integer primary keys 145 | 146 | # Place the following in migration script 147 | primary_key_to_uuid :colleges 148 | change_column :students, :institution_id, :string 149 | polymorphic_column_data_for_uuid :students, :institution, 'College' 150 | 151 | # When School also gets migrated to UUID primary key 152 | primary_key_to_uuid :schools 153 | columns_to_uuid :students, :institution_id 154 | 155 | # See the rspec test case in spec folder for full example 156 | ``` 157 | 158 | ## Generating random UUIDs 159 | 160 | You can provide a `seed` parameter with a valid UUID, which will be used to convert integers into random 161 | UUIDs using [uuid_generate_v5](https://www.postgresql.org/docs/current/uuid-ossp.html). You will only be 162 | able to recover the old IDs in the future if you use the seed you used during the migration to generate a 163 | rainbow table. 164 | 165 | Examples: 166 | 167 | ```ruby 168 | seed = SecureRandom.uuid 169 | 170 | primary_key_to_uuid :colleges, seed: seed 171 | columns_to_uuid :students, :institution_id, seed: seed 172 | polymorphic_column_data_for_uuid :students, :institution, 'College', seed: seed 173 | primary_key_and_all_references_to_uuid :cities: seed: seed 174 | 175 | ``` 176 | 177 | **Note:** Given a set of IDs (which are used, for example, as primary key in one table and foreign 178 | key in multiple tables), you will need to use the same seed in all method calls. Otherwise, the 179 | conversion will happen differently in each one of the tables, and the relations will be effectively 180 | lost. In general, it is advised to use a different seed per ID set (primary key) to maintain data 181 | consistency and guarantee uniqueness across tables. 182 | 183 | ## Compatibility 184 | 185 | As on October 06, 2023, actively tested with the following: 186 | - Rails 4.2 to 7.1 187 | - Ruby 2.5 to 3.1 188 | - Postgres 9.6 to 16 189 | 190 | Please see [workflows/ubuntu.yml](.github/workflows/ubuntu.yml) for the current test matrix. 191 | 192 | Update to latest version (>=1.4.0) for using it with Ruby 3. 193 | 194 | See https://travis-ci.org/kreatio-sw/webdack-uuid_migration for current build matrix. 195 | 196 | To run the test suite: 197 | 198 | # Update connection parameters in `spec/support/pg_database_helper.rb`. 199 | # Postgres user must have rights to create/drop database and create extensions. 200 | $ bundle exec rspec spec 201 | 202 | ## Credits 203 | 204 | - Users of the Gem 205 | - [Felix Bünemann](https://github.com/felixbuenemann) for checking compatibility with Rails 4.1 206 | - [Nick Schwaderer](https://github.com/Schwad) Rails 5.2.x compatibility 207 | - [Kelsey Hannan](https://github.com/KelseyDH) Upgrading to `pgcrypto` 208 | - [Sébastien Dubois](https://github.com/sedubois) Ruby 3.0 compatibility 209 | - [Manuel Bustillo Alonso](https://github.com/bustikiller) Random UUID support 210 | 211 | ## Contributing 212 | 213 | 1. Fork it ( http://github.com/kreatio-sw/webdack-uuid_migration/fork ) 214 | 2. Create your feature branch (`git checkout -b my-new-feature`) 215 | 3. Commit your changes (`git commit -am 'Add some feature'`) 216 | 4. Push to the branch (`git push origin my-new-feature`) 217 | 5. Create new Pull Request 218 | -------------------------------------------------------------------------------- /spec/uuid_migrate_helper_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | class ActiveRecordMigration 4 | def self.seed_with(seed) 5 | @@seed = seed 6 | self 7 | end 8 | 9 | def seed 10 | @@seed 11 | end 12 | end 13 | 14 | class BasicMigration < ActiveRecordMigration 15 | def change 16 | reversible do |dir| 17 | dir.up do 18 | enable_extension 'pgcrypto' 19 | enable_extension 'uuid-ossp' 20 | 21 | primary_key_to_uuid :students, seed: seed 22 | columns_to_uuid :students, :city_id, :institution_id, seed: seed 23 | end 24 | 25 | dir.down do 26 | raise ActiveRecord::IrreversibleMigration 27 | end 28 | end 29 | end 30 | end 31 | 32 | class MigrateAllOneGo < ActiveRecordMigration 33 | def change 34 | reversible do |dir| 35 | dir.up do 36 | enable_extension 'pgcrypto' 37 | enable_extension 'uuid-ossp' 38 | 39 | primary_key_to_uuid :cities, seed: seed 40 | primary_key_to_uuid :colleges, seed: seed 41 | primary_key_to_uuid :schools, seed: seed 42 | 43 | primary_key_to_uuid :students, seed: seed 44 | columns_to_uuid :students, :city_id, :institution_id, seed: seed 45 | end 46 | 47 | dir.down do 48 | raise ActiveRecord::IrreversibleMigration 49 | end 50 | end 51 | end 52 | end 53 | 54 | class MigrateWithFk < ActiveRecordMigration 55 | def change 56 | reversible do |dir| 57 | dir.up do 58 | enable_extension 'pgcrypto' 59 | enable_extension 'uuid-ossp' 60 | 61 | primary_key_and_all_references_to_uuid :cities, seed: seed 62 | end 63 | 64 | dir.down do 65 | raise ActiveRecord::IrreversibleMigration 66 | end 67 | end 68 | end 69 | end 70 | 71 | class MigrateStep01 < ActiveRecordMigration 72 | def change 73 | reversible do |dir| 74 | dir.up do 75 | enable_extension 'pgcrypto' 76 | enable_extension 'uuid-ossp' 77 | 78 | primary_key_to_uuid :cities, seed: seed 79 | primary_key_to_uuid :colleges, seed: seed 80 | 81 | primary_key_to_uuid :students, seed: seed 82 | columns_to_uuid :students, :city_id, seed: seed 83 | 84 | change_column :students, :institution_id, :string 85 | polymorphic_column_data_for_uuid :students, :institution, 'College', seed: seed 86 | end 87 | 88 | dir.down do 89 | raise ActiveRecord::IrreversibleMigration 90 | end 91 | end 92 | end 93 | end 94 | 95 | class MigrateStep02 < ActiveRecordMigration 96 | def change 97 | reversible do |dir| 98 | dir.up do 99 | primary_key_to_uuid :schools, seed: seed 100 | polymorphic_column_data_for_uuid :students, :institution, 'School', seed: seed 101 | end 102 | 103 | dir.down do 104 | raise ActiveRecord::IrreversibleMigration 105 | end 106 | end 107 | end 108 | end 109 | 110 | describe Webdack::UUIDMigration::Helpers do 111 | def initial_setup 112 | init_database 113 | create_initial_schema 114 | reset_columns_data # Ensure to reset the column data before sample data creation. 115 | populate_sample_data 116 | end 117 | 118 | def reset_columns_data 119 | [City, College, School, Student].each{|klass| klass.reset_column_information} 120 | end 121 | 122 | def key_relationships 123 | [ 124 | Student.order(:name).map { |s| [s.name, s.city ? s.city.name : nil, s.institution ? s.institution.name : nil] }, 125 | City.order(:name).map { |c| [c.name, c.students.order(:name).map(&:name)] }, 126 | School.order(:name).map { |s| [s.name, s.students.order(:name).map(&:name)] }, 127 | College.order(:name).map { |c| [c.name, c.students.order(:name).map(&:name)] } 128 | ] 129 | end 130 | 131 | before(:each) do 132 | initial_setup 133 | end 134 | 135 | [nil, SecureRandom.uuid].each do |seed| 136 | context "when the seed is #{seed}" do 137 | let(:seed_value) { seed } 138 | 139 | describe 'Basic Test' do 140 | it 'should migrate keys correctly' do 141 | # Select a random student 142 | student = Student.all.to_a.sample 143 | 144 | # Store these values to check against later 145 | original_name= student.name 146 | original_ids= [student.id, student.city_id, student.institution_id].map{|i| i.to_i} 147 | 148 | # Migrate and verify that all indexes and primary keys are intact 149 | expect { 150 | BasicMigration.seed_with(seed_value).migrate(:up) 151 | reset_columns_data 152 | }.to_not change { 153 | indexes= Student.connection.indexes(:students).sort_by { |i| i.name }.map do |i| 154 | [i.table, i.name, i.unique, i.columns, i.lengths, i.orders, i.where] 155 | end 156 | 157 | [indexes, Student.connection.primary_key(:students)] 158 | } 159 | 160 | # Verify that our data is still there 161 | student= Student.where(name: original_name).first 162 | 163 | # Verify that data in id columns have been migrated to UUID by verifying the format 164 | [student.id, student.city_id, student.institution_id].each do |id| 165 | expect(id).to match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) 166 | end 167 | 168 | unless seed_value 169 | # Verify that it is possible to retirve original id values (only without seed!) 170 | ids= [student.id, student.city_id, student.institution_id].map{|i| i.gsub('-','').to_i} 171 | expect(ids).to eq(original_ids) 172 | end 173 | 174 | # Verify that schema reprts the migrated columns to be uuid type 175 | columns= Student.connection.columns(:students) 176 | [:id, :city_id, :institution_id].each do |column| 177 | expect(columns.find{|c| c.name == column.to_s}.type).to eq :uuid 178 | end 179 | 180 | # Verify that primary key has correct default 181 | expect(columns.find{|c| c.name == 'id'}.default_function).to eq 'gen_random_uuid()' 182 | end 183 | end 184 | 185 | it 'should migrate entire database in one go' do 186 | expect { 187 | MigrateAllOneGo.seed_with(seed_value).migrate(:up) 188 | reset_columns_data 189 | }.to_not change { 190 | key_relationships 191 | } 192 | end 193 | 194 | it 'should migrate a primary key and all columns referencing it using foreign keys', rails_4_2_or_newer: true do 195 | # Create 2 more tables similar to the way new version of Rails will do 196 | create_tables_with_fk 197 | 198 | # Add Foreign key for this reference as well 199 | ActiveRecord::Base.connection.add_foreign_key :students, :cities 200 | 201 | expect { 202 | MigrateWithFk.seed_with(seed_value).migrate(:up) 203 | reset_columns_data 204 | }.to_not change { 205 | key_relationships 206 | } 207 | end 208 | 209 | it 'should handle nulls' do 210 | Student.create(name: 'Student without city or institution') 211 | 212 | expect { 213 | MigrateAllOneGo.seed_with(seed_value).migrate(:up) 214 | reset_columns_data 215 | }.to_not change { 216 | key_relationships 217 | } 218 | end 219 | 220 | it 'should migrate in steps for polymorphic association' do 221 | expect { 222 | MigrateStep01.seed_with(seed_value).migrate(:up) 223 | reset_columns_data 224 | }.to_not change { 225 | key_relationships 226 | } 227 | 228 | expect { 229 | MigrateStep02.seed_with(seed_value).migrate(:up) 230 | reset_columns_data 231 | 232 | }.to_not change { 233 | key_relationships 234 | } 235 | end 236 | 237 | it 'should allow running same migration data even if it was already migrated' do 238 | expect { 239 | MigrateStep01.seed_with(seed_value).migrate(:up) 240 | # Run again 241 | MigrateStep01.seed_with(seed_value).migrate(:up) 242 | reset_columns_data 243 | }.to_not change { 244 | key_relationships 245 | } 246 | 247 | expect { 248 | MigrateStep02.seed_with(seed_value).migrate(:up) 249 | # Run again 250 | MigrateStep02.seed_with(seed_value).migrate(:up) 251 | reset_columns_data 252 | }.to_not change { 253 | key_relationships 254 | } 255 | end 256 | 257 | it 'should allow updation, deletion, and new entity creation' do 258 | MigrateAllOneGo.seed_with(seed_value).migrate(:up) 259 | reset_columns_data 260 | 261 | # Select a random student 262 | student = Student.all.to_a.sample 263 | 264 | id= student.id 265 | student.name= 'New student 01' 266 | student.save 267 | student = Student.find(id) 268 | 269 | expect(student.name).to eq 'New student 01' 270 | 271 | expect { student.destroy }.to change { Student.count }.by(-1) 272 | 273 | expect {Student.find(id)}.to raise_exception(ActiveRecord::RecordNotFound) 274 | 275 | student= Student.create( 276 | name: 'New student 02', 277 | city: City.where(name: 'City 2').first, 278 | institution: School.where(name: 'School 1').first 279 | ) 280 | 281 | expect(City.where(name: 'City 2').first.students.where(name: 'New student 02').first.name).to eq 'New student 02' 282 | expect(School.where(name: 'School 1').first.students.where(name: 'New student 02').first.name).to eq 'New student 02' 283 | 284 | College.where(name: 'College 3').first.students << student 285 | 286 | student.reload 287 | 288 | expect(student.institution.name).to eq 'College 3' 289 | end 290 | end 291 | end 292 | end 293 | --------------------------------------------------------------------------------