Rails：マイグレーションファイル、スキーマファイル におけるタイムスタンプの役割

## はじめに

### 目的

みなさん、Railsで開発される際には、基本的にマイグレーションファイルを使用してDBの変更を行っていると思います。

個人開発の場合は、特に気にすることなく、マイグレーションファイルを積んで`db:migrate`を実行、とあまり迷うことはありませんが、
複数人での開発時に、**どのマイグレーションファイルが実行されている/いないのか**、**db:migrateを行うとschema.rbのタイムスタンプが変更されるが、どんな意味を持っているのか**などが分からなく、混乱したことはないでしょうか

そこで、今回は

1.マイグレーションファイルの実行有無がどのように管理されてるのか
2.schema.rbのタイムスタンプにどのように反映され、なんの意味を持つのか

など、マイグレーションの実行の有無とタイムスタンプの関係について説明します。

### 実行環境

Ruby: 2.6.5
Rails: 5.2.3
DB: postgresql

コード：mastodon(https://github.com/tootsuite/mastodon)
をサンプルに使っています。コードが実際のものになるので分かりやすくなるかなと思って使っただけで、特に深い意味はありません。


## マイグレーションの基礎

マイグレーションの基礎については、Railsガイドや他のQiitaの記事で十分に説明されているので、今回は軽く流すだけに留めます。
https://railsguides.jp/active_record_migrations.html

### マイグレーションファイルとは

マイグレーションとは(引用：Railsガイド)
>マイグレーション (migration) はActive Recordの機能の1つであり、データベーススキーマを長期にわたって安定して発展・増築し続けることができるようにするための仕組みです。マイグレーション機能のおかげで、Rubyで作成されたマイグレーション用のDSL (ドメイン固有言語) を用いて、テーブルの変更を簡単に記述できます。スキーマを変更するためにSQLを直に書いて実行する必要がありません。

簡単に言うと使用するDBMS(mysqlやpostgres)に関わらず、一定の書き方でテーブルに変更を加えることができる機能ですね。

### マイグレーションの仕組み

マイグレーションを行う際には、３つの登場人物が存在します。

+ マイグレーションファイル
    + データベースに対して行いたい変更を記述するもの
+ スキーマファイル(schema.rb)
    + 現状のデータベースの状態を保存するもの
+ データベース


実行と結果流れとしては以下の通りになります。

 1.　マイグレーションファイルに変更内容を書く
 2.　マイグレーションを実行(`db:migrate`)することで、データベースの内容が変更される
 3.　変更された内容のデータベースの状態で、schema.rbが更新される
 4.　schema.rbのタイムスタンプが更新される

※このあたりの詳しい使い方やコマンドについてはRailsガイドなどを参照すると良いかと思います。

## 実行有無の確認

ここからが本題です。

マイグレーションファイルは変更が加えられるごとにその数が増えていきます。
さらに複数人開発では、自分が作成したもの、他の人が作成したものなどが入ってきて、実行したか/していないかがわかりづらくなります。

ただ、Railsではdb:migrateのタスクを実行すると、未実行のもののみが反映され、DBが更新されます。

+ **このマイグレーションの実行有無を管理しているものは何でしょうか？**

また、

+ **migrationファイルのファイル名、schema.rb内にそれぞれ使われているタイムスタンプはどのような意味を持っているのでしょうか？**

```ruby:schema.rb
ActiveRecord::Schema.define(version: 2019_10_31_163205) do

  # These are extensions that must be enabled in order to support this database
  enable_extension "plpgsql"
```

### マイグレーションファイルの実行有無

マイグレーションファイルの中で、その実行有無を確認するためには以下のコマンドを使用します。

```
rake(rails) db:migrate:status
```

この結果は以下のようになります。
upとなっているのが実行済み、downは未実行のマイグレーションファイルです。

```
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
   up     20190927232842  Add voters count to polls
   up     20191001213028  Add lock version to account stats
  down    20191031163205  Change list account follow nullable
```


マイグレーションファイルの実行有無を検証しているのは「**schema_migrations**」と言うテーブルです。
`db:migrate`を実行した時にこのschema_migrationのversionカラムに以下のようにタイムスタンプが保存されます。

```
development=# select * from schema_migrations
order by version desc;
    version     
----------------
 20191031163205
 20191007013357
 20191001213028
 20190927232842
〜〜〜〜〜〜〜〜〜〜〜
```

このタイムスタンプはマイグレーションファイルの先頭のタイムスタンプと一致します。
<img width="324" alt="スクリーンショット 2019-11-09 16.49.16.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/297271/db55f896-6d46-499d-a8cc-7ac078b24ca9.png">

実際に`db:migrate`を実行した際のログを見てみましょう。

今回は初めて`db:migrate`を実行したので、**schema_migrationsテーブルがcreate**されていることがわかります。

初回の`db:migrate`では**テーブルが作成され、versionにタイムスタンプのレコードが追加**されます。その後は実行するたびに、versionにタイムスタンプがinsertされます。

```
 be rails db:migrate
   # 初めてdb:migrateを行なったので、schema_migrationsテーブルが作成される
   (4.7ms)  CREATE TABLE "schema_migrations" ("version" character varying NOT NULL PRIMARY KEY)
   (0.2ms)  BEGIN
== 20160221003140 CreateUsers: migrating ======================================
-- create_table(:users, {:id=>:integer})
   (4.3ms)  CREATE TABLE "users" ("id" serial NOT NULL PRIMARY KEY, "email" character varying DEFAULT '' NOT NULL, "account_id" integer NOT NULL, "created_at" timestamp NOT NULL, "updated_at" timestamp NOT NULL)
   -> 0.0052s
== 20160221003140 CreateUsers: migrated (0.0087s) =============================

  # 実行されたマイグレーションファイルのファイル名に入っているタイムスタンプが、versionカラムに追加されている
  ActiveRecord::SchemaMigration Create (0.5ms)  INSERT INTO "schema_migrations" ("version") VALUES ($1) RETURNING "version"  [["version", "20160221003140"]]
   (0.6ms)  COMMIT
   (0.2ms)  SELECT "schema_migrations"."version" FROM "schema_migrations" ORDER BY "schema_migrations"."version" ASC
```

ちなみに`db:rollback`ではversionから該当するタイムスタンプのレコードが削除されます。

そして、schema_migrationsが更新され、schema.rbのタイムスタンプは、versionに入っている中で一番値の大きい（新しい）で更新が行われます。

これによって、**どのマイグレーションファイルが実行されたのか/されていないのか**を判断することができるのです。



### schema.rbにおけるタイムスタンプの意味

以上のことからマイグレーションファイルとschema.rbとの関係性が以下であると言うことがわかりました。

+ マイグレーションファイルの実行有無はschema_migrationsテーブルで管理されている
+ schema.rbのタイムスタンプは、マイグレーション実行後に、schema_migrationsの最大値で更新される

ここから判断すると、schema.rbのタイムスタンプは
「マイグレーションファイルの実行有無の管理には**影響を受けるものであり、影響を与えるものではない。**」
ということがわかります。

schema.rbのタイムスタンプがなくとも、マイグレーションファイルの実行有無は管理できており、マイグレーションでテーブルを変更をしていく分には、schema.rbのタイムスタンプは特別な意味を持たないというになるでしょう。

試しに、schema.rbのタイムスタンプを数年前にしてみましたが、

```ruby:schema.rb
ActiveRecord::Schema.define(version: 2017_10_31_163205) do
```
`db:migrate:status`の実行結果は以下の通り、変わりません。

```
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
   up     20190927232842  Add voters count to polls
   up     20191001213028  Add lock version to account stats
  down    20191031163205  Change list account follow nullable
```


では、なぜschema.rbのタイムスタンプが使用されるのはどのような場合でしょうか。
それは、テーブル情報を`db:schema:load`によって変更する場合（他にもあるかもしれませんが）です。


### db:schema:loadについて

`db:schema:load`についても簡単に説明しておきます。

`db:schema:load`は、schema.rbの情報をもとに、テーブル情報を変更します。
ちなみに気をつけたいのは、すでにテーブルが存在した場合には、そのテーブルを削除して作り直します。基本的に**DBを作成したときにのみ行うもの**ですね。


`db:migrate`はmigrationファイルを実行するので、schema_migrationsに変更したマイグレーションファイルのタイムスタンプが追加されるのはわかりやすいですが、`db:schema:load`を行った場合にはschema_migrationsの扱いはどうなるのでしょうか。

実際にdbを新規作成して、`db:schema:load`を行なってみます。

使用するschema.rbのタイムスタンプ、migrationファイルの一覧は以下のようになっています。

```ruby:schema.rb
ActiveRecord::Schema.define(version: 2019_10_31_163205) do
```

<img width="324" alt="スクリーンショット 2019-11-09 16.49.16.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/297271/db55f896-6d46-499d-a8cc-7ac078b24ca9.png">


`db:create`した後に、`db:schema:load`を行い、schema_migrationテーブルが作成されているかを確認しました。

```
development=# select * from schema_migrations
order by version desc;
    version     
----------------
 20191031163205
 20191007013357
 20191001213028
 20190927232842
〜〜〜〜〜〜〜〜〜〜〜
```

db:migrate:statusの結果

```
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
   up     20190927124642  Remove invalid web push subscription
   up     20190927232842  Add voters count to polls
   up     20191001213028  Add lock version to account stats
   up     20191007013357  Update pt locales
   up     20191031163205  Change list account follow nullable
```

この場合にも、schema_migrationファイルが作成され、migrationファイルのタイムスタンプが入った状態が確認できました。

**ここで使われるのているのが、schema.rbファイルに示されているタイムスタンプです。**

```ruby:schema.rb
ActiveRecord::Schema.define(version: 2019_10_31_163205) do

  # These are extensions that must be enabled in order to support this database
  enable_extension "plpgsql"
```

この**タイムスタンプ(2019_10_31_163205)までの日時を示しているmigrationファイルを、実行されたこととして、schema_migrationファイルのversionが追加されます。**


これを確認するため、一度`db:drop`, `db:create`で作成し直し、schema.rbのタイムスタンプの日付を変更した上で`db:schema:load`してみます。

```ruby:schema.rb
ActiveRecord::Schema.define(version: 2019_10_01_213028) do
```

schema_migrationのversionは以下のようになりました

```
development=# select * from schema_migrations
order by version desc;
    version     
----------------
 20191001213028
 20190927232842
 20190927124642
```

念のため、`db:migrate:status`の結果も見ておきましょう

```
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
   up     20190927232842  Add voters count to polls
   up     20191001213028  Add lock version to account stats
  down    20191007013357  Update pt locales
  down    20191031163205  Change list account follow nullable
```

schema.rbの変更されたタイムスタンプまでのmigrationファイルが実行されている状態が確認できました！

## まとめ

ここまでを簡単まとめると以下のようになります。

+ **マイグレーションファイルの実行有無は、ファイル名のタイムスタンプをもとに、schema_migrationsテーブルによって管理される**
+ **`db:migrate`を行なった際に、ファイル名のタイムスタンプがschema_migrationsに保存された上、実行されたschema_migrations内の最新のタイムスタンプが、schema.rbのタイムスタンプとして登録される**
+ **`db:schema:load`は、schema.rbのタイムスタンプ以前のファイル名を持ったマイグレーションファイルを実行されたものとして記録する**

と言うことです。

### タイムスタンプ管理で問題がおきそうなケース

最後に、今回まとめた知識をもとに、複数人開発でタイムスタンプによる問題がおきそうなケースを考えて終わりにしましょう。

まず、マイグレーションでDBを更新していく場合には、実際に実行されたものだけがschema_migrationsに保存されているので、DBの更新のみで考えれば問題はなさそうです。(「schema.rbのテーブル情報が更新されておらず、差分が発生した」などのコード管理の問題は別として)

問題となりそうなのは、新しい開発者などが入って、`db:schema:load`によって、テーブル情報を作成する場合です。

ここでもし、最新のコードが以下の状態になっていればどうなるでしょうか？

+ 開発者AがマイグレーションファイルAを作成したが、`db:migrate`を行わずにschema.rbを更新せずにコミットした
+ その後、別の開発者Bが上記のマイグレーションファイルAよりも新しいタイムスタンプでマイグレーションファイルBを作成、`db:migrate`を実行し、schema.rbも更新した上でコミットした
+ 上記のコード状態で、新しく入った開発者Cが`db:create`、`db:schema:load`を行なった

開発者Aから見たmigrationファイルの状態

```
   up     20190927232842  Add voters count to polls
  down    20191001213028  Add lock version to account stats
   up     20191031163205  Change list account follow nullable
```

この場合、schema.rbのタイムスタンプはマイグレーションファイルBのものになっています。

```ruby:schema.rb
ActiveRecord::Schema.define(version: 2019_10_31_163205) do

```

そのため、`db:schema:load`を行うと、マイグレーションファイルAも実行されたこととして、schema_migrationsに記録されます。

この状態で`db:rollback`を行おうとすると、実行されていないマイグレーションファイルAもロールバック対象となるため、例えばマイグレーションファイルAの内容がテーブルの作成であれば、存在しないテーブルを削除しようとし、エラーが発生します。

`db:schema:load`後に、マイグレーションファイルAの`db:rollback`を実行


```
StandardError: An error has occurred, all later migrations canceled:

PG::UndefinedColumn: ERROR:  column "lock_version" of relation "account_stats" does not exist
: ALTER TABLE "account_stats" DROP COLUMN "lock_version"
```

他にも色々と問題は起こるかもしれませんが、一例として挙げてみました。

### 最後に

最後までご覧頂き、ありがとうございます。
もし内容の間違いや、こんなケースも考えられるということがあれば、是非おしらせください。