DEV Community

Niko
Niko

Posted on

SafeMigrations: A Rails Gem for Easy Migrations

I've spent years working on Rails projects, and migrations have often been a source of frustration. Have you ever had a migration fail because a table or column already existed? Or seen a rollback cause issues because Rails' change method couldn't handle custom logic?
I faced these problems repeatedly, which led me to create safe_migrations, a gem that makes Rails migrations idempotent and reversible while preserving the change method's simplicity. Here's the story of how I built it and why you should try it.

The Migration Problem

In one Rails 4.2 project, I encountered migrations like this:

class AddUseLogoutPageToAccounts < ActiveRecord::Migration[4.2]
  def change
    column_exists?(:accounts, :use_logout_page) || add_column(:accounts, :use_logout_page, :boolean, default: false)
  end
end
Enter fullscreen mode Exit fullscreen mode

The column_exists? || add_column check was necessary to prevent errors when re-running migrations, but it was verbose and error-prone. Worse, rollbacks (rails db:rollback) were problematic. Rails' change method is designed to automatically reverse operations, but it didn't understand my custom checks. If the column existed before the migration, add_column would skip, but the rollback might still call remove_column, risking unintended schema changes.
To address this, I created a helper module with a safe_add_column method:

module SafeMigrationHelper
  def safe_add_column(table, column, type, **options)
    add_column(table, column, type, **options) unless column_exists?(table, column)
  end
end
Enter fullscreen mode Exit fullscreen mode

This allowed cleaner migrations:

class AddUseLogoutPageToAccounts < ActiveRecord::Migration[4.2]
  def change
    safe_add_column(:accounts, :use_logout_page, :boolean, default: false)
  end
end
Enter fullscreen mode Exit fullscreen mode

Running rails db:migrate or VERSION=20180801004438 rails db:migrate:up worked well - no errors if the column existed. However, rollbacks remained an issue. Rails' CommandRecorder didn't recognize safe_add_column, so rails db:rollback wouldn’t invert it correctly. If the column pre-existed, the migration skipped adding it, but the rollback might still attempt to remove it.
With time constraints, we used explicit up and down methods:

class AddUseLogoutPageToAccounts < ActiveRecord::Migration[4.2]
  def up
    safe_add_column(:accounts, :use_logout_page, :boolean, default: false)
  end

  def down
    safe_remove_column(:accounts, :use_logout_page, :boolean)
  end
end
Enter fullscreen mode Exit fullscreen mode

This was reliable but lost the elegance of the change method. I wanted a solution that combined idempotency with automatic rollbacks.

Introducing safe_migrations

After further development, I created the safe_migrations gem to solve these issues. It provides:

  • Idempotent Methods: Methods like safe_create_table, safe_add_column, and safe_add_index check for existing schema elements before executing.
  • CommandRecorder Integration: Extends ActiveRecord::Migration::CommandRecorder to register safe_ methods and their inverses (e.g., safe_add_column to safe_remove_column), enabling automatic rollbacks in change.
  • Rails 7 Support: Built for Rails 7.0+ and Ruby 3.2, with RSpec tests for reliability.

Here’s an example:

class AddUseLogoutPageToAccounts < ActiveRecord::Migration[7.0]
  def change
    safe_add_column(:accounts, :use_logout_page, :boolean, default: false)
    safe_add_index(:accounts, :use_logout_page)
  end
end
Enter fullscreen mode Exit fullscreen mode

Run rails db:migrate, and it adds the column and index only if needed. Run rails db:rollback, and CommandRecorder inverts to safe_remove_column and safe_remove_index.

Important Caveat

One limitation: CommandRecorder inverts all commands in change, even if they didn’t execute. For example:

class CreateAccounts < ActiveRecord::Migration[7.0]
  def change
    safe_create_table(:accounts) do |t|
      t.string :name
    end
  end
end
Enter fullscreen mode Exit fullscreen mode
  • up: safe_create_table skips if :accounts exists.
  • down: CommandRecorder calls safe_drop_table, which may drop a pre-existing table.

I believe this is a rare scenario in most projects, and safe_migrations is reliable for typical use cases, but I'm eager to hear your ideas for further improvements.

Try safe_migrations

If migrations have caused you headaches, safe_migrations can help. Install it with:

gem install safe_migrations
Enter fullscreen mode Exit fullscreen mode

Or add to your Gemfile:

gem 'safe_migrations', '~> 1.0'
Enter fullscreen mode Exit fullscreen mode

Check the GitHub repo for docs and examples. It's open-source (MIT license), so contributions are welcome!

Your Feedback Matters

I built safe_migrations to simplify Rails migrations, but I'd love to hear your experiences. Have you faced similar migration challenges? How does the gem work for you? Have ideas to improve it? Comment below, open a GitHub issue, or reach out. Let's make migrations reliable together!

Top comments (0)