schema.rbで差分が発生する事例とその復旧について

Railsには、スキーマファイルと呼ばれる schema.rb があります。

Railsガイドには

Active Recordはマイグレーションの時系列に沿ってスキーマを更新する方法を知っているので、履歴のどの時点からでも最新バージョンのスキーマに更新できます。Active Recordは db/schema.rb ファイルを更新し、データベースの最新の構造と一致するようにします。

とあるように、schema.rb はデータベース関連の機能で使われます。

その schema.rb ですが、チームで開発をしている時に

  • 自分の作業を中断して、プルリクエストのレビューをする

  • 自分の作業に戻って、開発を続ける

を繰り返している中で、意図しない差分が schema.rb に発生していると気づき、戸惑うことがあるかもしれません。

そこで、schema.rb に差分が発生してしまうことについて

  • 発生する事例

  • 差分が発生していると何が起こるか

  • どう復旧するか

を記事としてまとめます。

発生する事例

発生する事例として、「どのような流れで発生してしまうのか」を体験できるチュートリアルを用意しました。

チュートリアルは以下の流れで進めます。なお、Railsのバージョンは 7.0.4.2 を想定しています。

  1. mainブランチで、Railsのモデルを作成

  2. mainから派生した第2のブランチ(feature/add_unique_index)で、モデルの変更を伴うマイグレーションを適用

  3. mainから派生した第3のブランチ(feature/add_column)で、モデルの変更を伴うマイグレーションを適用

1. mainブランチで、Railsのモデルを作成

最小構成で rails new します。後ほどgemを追加するため、現時点では bundle install を行いません。

% bundle exec rails new schemaapp --minimal --skip-bundle

% cd schemaapp

今回の動作確認をRSpecで行うため、 Gemfile の末尾に追加します。

group :test do
  gem 'rspec-rails', '~> 6.0.1'
end

Gemfileの準備ができたため、一連のgemをインストールします。

% bundle install

RSpecのセットアップも行います。

% bin/rails g rspec:install

続いてモデルを用意します。今回は isbn 列を持つBookモデルとします。

% bin/rails g model Book isbn:string

マイグレーション適用後に、状態を確認しておきます。

% bin/rails db:migrate

% bin/rails db:migrate:status

database: db/development.sqlite3

 Status   Migration ID    Migration Name
--------------------------------------------------
   up     20230306095752  Create books

ここまでをコミットします。 (もし vendor/bundle 以下をコミットしたくない場合は .gitignore に追加しておきます)

% git init
% git add .
% git commit -m "first commit"

2. mainから派生した第2のブランチ(feature/add_unique_index)で、モデルの変更を伴うマイグレーションを適用

main ブランチから第2のブランチ( feature/add_unique_index )を新しく作成し、「Bookの isbn にUNIQUE制約を追加する」ためのマイグレーションファイルを生成します。

% git checkout -b feature/add_unique_index

% bin/rails g migration AddIndexToBook

生成したマイグレーションファイルにて、Bookの isbnにUNIQUE制約を追加します。

class AddIndexToBook < ActiveRecord::Migration[7.0]
  def change
    add_index :books, :isbn, unique: true
  end
end

マイグレーションを適用後、状況を確認します。

% bin/rails db:migrate

% bin/rails db:migrate:status

database: db/development.sqlite3

 Status   Migration ID    Migration Name
--------------------------------------------------
   up     20230306095752  Create books
   up     20230306104025  Add index to book

作業が終わったため、コミットします。

% git add .

% git commit -m "add index"

3. mainから派生した第3のブランチ(feature/add_column)で、モデルの変更を伴うマイグレーションを適用

ここからが、意図しない差分を発生させてしまう手順になります。

本来ならば、第2のブランチ(feature/add_unique_index)を離れる前にマイグレーションをロールバックします。

ただ、今回は schema.rbに差分を発生させるため、マイグレーションを適用したまま、

  • 第3のブランチ(feature/add_column)を新しく作成・切り替え

  • モデルに列 nameを追加

  • マイグレーションを適用

を行います。

% git checkout main

% git checkout -b feature/add_column

% bin/rails g migration AddColumnToBook name:string

% bin/rails db:migrate

続いて、マイグレーションの状態を確認します。作業2.のマイグレーションが適用されたまま、今回のマイグレーションも適用されているのが分かります。

% bin/rails db:migrate:status

database: db/development.sqlite3

 Status   Migration ID    Migration Name
--------------------------------------------------
   up     20230306095752  Create books
   up     20230306104025  ********** NO FILE **********
   up     20230306104231  Add column to book

schema.rb を見ると isbn 列にUNIQUE制約が定義されており、意図しない状態となっています。

ActiveRecord::Schema[7.0].define(version: 2023_03_06_104231) do
  create_table "books", force: :cascade do |t|
    t.string "isbn"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.string "name"
    t.index ["isbn"], name: "index_books_on_isbn", unique: true
  end
end

最後に、「この状態に気づかずに、うっかりコミット」した想定にしておきます。

% git add .

% git commit -m "add column"

差分が発生していると何が起きるか

今回はテストコードを使って、何が起こるかを見ていきます。

「第3のブランチ(feature/add_column)ではまだUNIQUE制約を付けるマイグレーションがないから、テストはパスするだろう」と考え、以下のテストコードを書いたとします。

require 'rails_helper'

RSpec.describe Book, type: :model do
  describe 'schema.rbの確認' do
    before { Book.create!(isbn: '4797399848') }

    it 'UNIQUE制約がないこと' do
      expect { Book.create!(isbn: '4797399848') }.not_to raise_error
    end
  end
end

しかし、RSpecを実行すると

expected no Exception, got #<ActiveRecord::RecordNotUnique: SQLite3::ConstraintException: UNIQUE constraint failed: books.isbn> with backtrace:

とテストが失敗してしまいます。

これは、Railsガイドで解説があるように、テストでは schema.rb を見てテスト用のデータベースを作っているためです。

今回の場合、UNIQUE制約を追加するマイグレーションファイルが存在しない第3のブランチ(feature/add_column)でも、UNIQUE制約付きでテスト用のデータベースが作成されてしまった結果、テストが失敗しました。

どう復旧するか

今回は、以下の流れで復旧していきます。

  1. UNIQUE制約を追加した第2のブランチ(feature/add_unique_index)へ切り替え、UNIQUE制約のマイグレーション適用をロールバックする

  2. 第3のブランチ(feature/add_column)へ切り替え、 db:migrate を実行する

順に見ていきます。

1. UNIQUE制約を追加した第2のブランチ(feature/add_unique_index)へ切り替え、UNIQUE制約のマイグレーション適用をロールバックする

UNIQUE制約を追加した第2のブランチ( feature/add_unique_index )へ切り替え、マイグレーションの状況を確認します。

Migration IDに対するNameのうち、NO FILE と表示されていたものが Add index to book という表示へと切り替わりました。

% git checkout feature/add_unique_index
% bin/rails db:migrate:status

database: db/development.sqlite3

 Status   Migration ID    Migration Name
--------------------------------------------------
   up     20230306095752  Create books
   up     20230306104025  Add index to book
   up     20230306104231  ********** NO FILE **********

続いて、第2のブランチ( feature/add_unique_index )上で、不要なマイグレーション適用を元に戻します。

なお、 db:migrate:rollback コマンドでは1ステップずつしか戻せないので、バージョンを指定できるコマンドで戻します。

% bin/rails db:migrate:down VERSION=20230306104025

再度マイグレーションの状況を確認すると、当該Migration IDが down という未適用状態になりました。

% bin/rails db:migrate:status    

database: db/development.sqlite3

 Status   Migration ID    Migration Name
--------------------------------------------------
   up     20230306095752  Create books
  down    20230306104025  Add index to book
   up     20230306104231  ********** NO FILE **********

ただ、この時点では schema.rb に変更が入っていることから、第3のブランチ(feature/add_column)へ切り替えようとしても

% git checkout feature/add_column
error: Your local changes to the following files would be overwritten by checkout:
	db/schema.rb
Please commit your changes or stash them before you switch branches.
Aborting

と、エラーになってしまいます。

そこで、 schema.rb の変更を、当ブランチのコミット時点に戻しておきます。

% git reset HEAD db/schema.rb
% git checkout db/schema.rb

2. 第3のブランチ(feature/add_column)へ切り替え、 db:migrate を実行する

あらためて第3のブランチ(feature/add_column)へ切り替えます。

% git checkout feature/add_column

ここでマイグレーション適用状況を確認すると、第3のブランチ(feature/add_column )のマイグレーションのみが適用・表示されています。

% bin/rails db:migrate:status    

database: db/development.sqlite3

 Status   Migration ID    Migration Name
--------------------------------------------------
   up     20230306095752  Create books
   up     20230306104231  Add column to book

ただ、まだ schema.rb にはUNIQUE制約の定義が残ったままになっています。

そこで、もう一回 db:migrate を実行し、 schema.rb を更新します。

% bin/rails db:migrate

ターミナルの実行結果には何も表示されないものの、git status ではschema.rb の更新が確認できます。

% git status
On branch feature/add_column
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
	modified:   db/schema.rb

更新後の schema.rb を見ると、UNIQUE制約の定義が削除されています。

ActiveRecord::Schema[7.0].define(version: 2023_03_06_104231) do
  create_table "books", force: :cascade do |t|
    t.string "isbn"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.string "name"
  end
end

これでようやく schema.rb が復旧しました。

あとは忘れずに schema.rbを第3のブランチ(feature/add_column)へコミットすれば、復旧は完了です。

おわりに

今回は schema.rbに差分が発生する事例とその復旧方法を見てきました。

復旧するのには手間がかかることから、もしプルリクエストにマイグレーションファイルが含まれている場合は、schema.rb に余分な更新がないかチェックするのが良さそうです。

本サイトの更新情報は、Twitterの株式会社プレセナ・ストラテジック・パートナーズエンジニア公式で発信しています。ご確認ください。

最終更新