Article summary
When working on a Rails project, you will inevitably need to move data around in your database. Some join table value will need to be moved into its own table or what have you. When approaching these kinds of migrations, there are two major complications: future-proofing and testing. In this post, let’s walk through an example migration.
Inserting Data
Rule of thumb: Do not use any ActiveRecord Models in a migration. As a project develops and changes, so do its classes. When building a migration, you want it to be reliable and reproducable. Migrations should behave exactly the same every time they are run. What if an after_save
hook has been added? What if the table_name
has changed? What if that class doesn’t even exist anymore?
We do know that migrations are tied to our schema.rb version and that we can depend on tables and columns, just not AR models. We use ar_class_for_table
to dynamically build a namespaced ActiveRecord model for the given table.
module ArClassForTable
def ar_class_for_table table, &blk
klass = Class.new ActiveRecord::Base
unique_module = Module.new
ArClassForTable.const_set "Id#{ArClassForTable.unique_id}", unique_module
unique_module.const_set table.to_s.classify, klass
klass.table_name = table
klass.class_eval &blk if block_given?
klass
end
def self.unique_id
@unique_id ||= 0
@unique_id += 1
end
end
ActiveRecord::Migration.extend ArClassForTable
In your migration, you can use it as a standard ActiveRecord model, but this one won’t shift out from under you with the rest of your codebase.
user_klass = ar_class_for_table(:users)
user_klass.where(...)
user_klass.create(...)
# etc..
Testing
Wrangling your database to be in just the right setup for a migration test can be tricky. I wish Rails had better baked-in harnessing for testing data migrations, but since I couldn’t find any, here are two options:
Never been squashed
If you have never squashed your migrations down, you still have all the migrations since the beginning of your project. Your test should look like this:
- Drop all tables.
- Migrate to the version just before the one you wish to test.
- Insert test data using
ar_class_for_table
or SQL. - Run migration.
- Assert results using
ar_class_for_table
or SQL.
Squashed migrations
If you have squashed your migrations by removing old ones and relying solely on your schema.rb to “catch you up,” we need to do a little more work:
- Drop all tables.
- Load schema.rb.
- Migrate down to the version just before the one you wish to test.
- Insert test data using
ar_class_for_table
or SQL. - Run migration.
- Assert results using
ar_class_for_table
or SQL.
Here are all the helpers needed to make the above testing plan work:
require 'spec_helper'
require Rails.root.join('db/migrate/20160712211943_populate_orgs_for_students')
describe MyMigrationThing, type: :migration do
def drop_all_tables
ActiveRecord::Base.connection.tables.each do |table|
ActiveRecord::Base.connection.drop_table(table)
end
end
def migrate(opts={})
quietly do
version = opts[:version] ? opts[:version].to_i : nil
down = opts[:dir] == :down
up = opts[:up] == :up
if down
ActiveRecord::Migrator.down(DB_MIGRATIONS_PATHS)
elsif up
ActiveRecord::Migrator.up(DB_MIGRATIONS_PATHS)
else
ActiveRecord::Migrator.migrate(DB_MIGRATIONS_PATHS, version)
end
end
end
def quietly
old_verbose = ActiveRecord::Migration.verbose
ActiveRecord::Migration.verbose = false
ActiveRecord::Base.logger.silence do
yield
end
ensure
ActiveRecord::Migration.verbose = old_verbose
end
before do
quietly do
drop_all_tables
load Rails.root.join('db/schema.rb')
migrate version: THIS_TEST_VERSION
migrate dir: :down
end
end
after do
drop_all_tables
load Rails.root.join('db/schema.rb')
migrate
end
it 'does a thing' do
# insert test data
migrate dir: :up
expect(...)
end
When dealing with the migration of production data, your code must be future-proof and well tested. I’ve presented a couple of options here. Leave your approaches in the comments.