最近、トリッキーなRails migrationを書いたので、共有しようと思います。
(カバー画像: Chris Barbalis on Unsplash)
Railsのマイグレーションとは
RailsはデフォルトではリレーショナルデータベースであるMySQL, PostgreSQL, SQLiteなどのデータベースを永続化に使用します(ActiveRecord)。これらのデータベースは、保存されるデータの構造をあらわすスキーマ (schema) をあらかじめ定義する形態をとっています。
アプリケーションが正しく動作するには、利用するデータベースのスキーマがアプリケーションの想定するものでなければいけないので、スキーマとアプリケーションコードの同期を取る必要があり、これをマイグレーション (migration) と呼んでいます。このスキーマとアプリケーションコードの同期をどう取るかが、この手のデータベースライブラリの課題の一つと言えます。多くの場合アプリケーションコードとデータベースの実体は1対多(development, test, staging, productionなど)の関係にあり、より複雑な場合は多対多の関係になることもあるという状況が、問題をより難しくしています。
アプリケーションとスキーマの整合性を保つ方法について、データベースライブラリによってアプローチが微妙に異なります。たとえば以下のような例があります。
- RailsのデフォルトのO/Rマッパーである ActiveRecord はそれ自体にマイグレーション機能があります。ActiveRecordは接続先データベースのスキーマがマスターで、そのコピーをアプリケーションコード(Ruby)に保持します。プログラマはマイグレーションをRubyで記述し、それをデータベースに適用します。適用後のスキーマを再びRubyコードとしてダンプします。
- Pythonの有名なO/Rマッパーである SQLAlchemy には Alembic というマイグレーションライブラリが用意されています。SQLAlchemyはアプリケーションコード(Python)で定義されたスキーマをマスターとし、実際のデータベースのスキーマとの差分からマイグレーションの雛形を生成します。
- Railsの代替マイグレーションライブラリである ridgepole ではSQLAlchemyと同様、アプリケーションコードをマスターとします。(ただしあくまでActiveRecordベースのため、O/Rマッパーの動作は接続先データベースのスキーマに基づくと思われます。) 実際のデータベースのスキーマとの差分から自動的にマイグレーションを実行します。
- Rustのクエリビルダーである diesel はActiveRecordと同様データベースのスキーマがマスターです。ただし、マイグレーションを生のSQLで記述する点と、クエリビルダーの動作はアプリケーションコード側に取り込まれたスキーマのコピーに基づくという点が異なります。
それぞれに設計上の制約やメリット・デメリットがあると思います。特に今回は以下の点に注目したいと思います。
- ここで紹介したもののうちridgepole以外の手法は、(何らかの理由で)複数のデータベース間でスキーマの差分が生じたときに、その差分が累積しうるという特徴があります。
- ActiveRecordではデータベースのスキーマから、Rubyで書かれたスキーマのコピーを生成しています。開発時にはこのRuby上のスキーマからデータベーススキーマをロードする寸法なのですが、これは必ずしも元のデータベースのスキーマを忠実に再現しているとはいえないため、Railsが想定していないような特殊なスキーマでは問題になる可能性があります。
スキーマの不整合
さて、最近まで私達のアプリケーションのスキーマには、以下のようなコードが含まれていました。
create_table "beta_tags", id: :serial, force: :cascade do |t|
# ...
end
# ...
create_table "tags", id: :serial, force: :cascade do |t|
# ...
end
BetaTagとTagという2つのモデルに対応する2つのテーブルがあるだけですね。
ところが、マスターである本番DBからRubyのスキーマコードを生成すると、以下のようになりました。
create_table "beta_tags", id: :integer, default: -> { "nextval('tags_id_seq'::regclass)" }, force: :cascade do |t|
# ...
end
# ...
create_table "tags", id: :serial, force: :cascade do |t|
# ...
end
これはどういうことでしょうか。本番DBにpsqlコマンドで入って調査してみると以下のようになりました。
wantedly=> \q
List of relations
Schema | Name | Type | Owner
--------+-------------------------------------------------+----------+--------
(...)
public | beta_tags | table | user
(...)
public | tags | table | user
public | tags_id_seq | sequence | user
public | tags_id_seq1 | sequence | user
(...)
PostgreSQLではサロゲートキー (serial) の管理のために専用のリレーションを生成するため、Railsの典型的なモデルは2つのリレーションの対になるはずですが、その対応関係が壊れてしまっています。
これは本来こうあるべきですね。
wantedly=> \q
List of relations
Schema | Name | Type | Owner
--------+-------------------------------------------------+----------+--------
(...)
public | beta_tags | table | user
public | beta_tags_id_seq | sequence | user
(...)
public | tags | table | user
public | tags_id_seq | sequence | user
(...)
アプリケーションコードに含まれていたRubyのスキーマ定義はこちらの状況を表しているため、開発用DBを生成するとこちらの正しい状態になります。
ではなぜこのような状況になったのでしょうか。歴史を正確に調査したわけではないですが、今ある状況から以下のような推測が立ちます。
- おそらくはじめに旧 Tag モデルがあり、 tags テーブルと tags_id_seq シーケンスがそれに対応していた。
- 開発のどこかのタイミングで、旧 Tag モデルを BetaTag にリネームした。このとき何らかの手動操作により tags テーブルを beta_tags にリネームしたが、 tags_id_seq シーケンスをリネームし損ねてしまった。
- 新 Tag モデルが追加された。このとき本番DBでは tags_id_seq という名前のシーケンスが既にあったため、 tags_id_seq1 という名前で新たに生成された。
- こうしてできたスキーマをダンプすると、tags_id_seq1 という名前のシーケンスはRailsが自然に生成した名前と認識できるため、既定の :serial キーとしてダンプされるが、 tags_id_seq は beta_tags の id に対するシーケンスとしてRailsが自然に生成する名前ではないため、カスタマイズされたプライマリキーとしてダンプされてしまう。
- 逆に "nextval('tags_id_seq'::regclass)" としてダンプされた既定値を開発用DBに書き戻そうとしても、tags_id_seqというシーケンスがその時点では存在しないためにエラーになってしまう。そのため、過去にRubyのスキーマファイルを本番DBに同期しようとした人達は、これを :serial による定義に手動で書き換えていたと推定される。
スキーマの不整合を修正する
さて、この不整合を修正したいと思います。単に本番DBのコンソールに入って、手作業でスキーマの不整合を修正してもいいかもしれません。ただ、アプリケーションコードに記録として残すことやレビュー可能性・再現性を高めることを考えると、単独のスクリプトかマイグレーションとして実装するのがよいと考えました。また、スキーマの変更点を集約することや、全ての関連するデータベースに修正を適用することを考え、独立したスクリプトではなくマイグレーションとして実装することにしました。
マイグレーションとして実装するときに気をつけるべきことは、複数のデータベースで正しく動作することです。今回のマイグレーションはスキーマの不整合を修正するためのものですが、はじめから不整合がないデータベースもあります。どちらでも正しく動くようにするには工夫が必要です。
また、このマイグレーションの実行後は不整合がない状態になるということは、逆に実行前に不整合があったかどうかという情報は消滅してしまうということです。つまり、厳密な意味での可逆なマイグレーションは不可能ということです(Railsのマイグレーションは可逆性を考慮して設計されています)。しかし、この些細なマイグレーションのせいでマイグレーションを遡りにくくなるのは不本意です。今回はマイグレーションの性質を考慮し、マイグレーションの逆適用時は何もしないという形で実装しました。
以下がその実装です。
class FixTagsIdSeq < ActiveRecord::Migration[5.2]
def change
reversible do |dir|
dir.up do
beta_tags_id_seq = select_value <<-SQL
SELECT 1 FROM pg_catalog.pg_class WHERE relname = 'beta_tags_id_seq' AND relkind = 'S';
SQL
unless beta_tags_id_seq
execute <<-SQL
ALTER SEQUENCE tags_id_seq RENAME TO beta_tags_id_seq;
SQL
end
execute <<-SQL
ALTER SEQUENCE IF EXISTS tags_id_seq1 RENAME TO tags_id_seq;
SQL
end
end
end
end
今回は逆適用が順適用の機械的な反転にはならないため、 reversible
で明示的に順適用と逆適用を分けて書いています。 (伝統的な #up
/ #down
で書いても同じです。)
この手の条件付き変更は IF EXISTS
や IF NOT EXISTS
のような条件節でうまく記述できる場合もあります。しかし今回は玉突き的に名前がずれているのでそれだけではできません。そこであまり使わない #select_value
というメソッドを使って、PostgreSQLのカタログテーブルからスキーマの現状を調べるクエリを発行しています。
最後に、このシーケンスを参照している側を名前の変更に追従させる方法を考える必要があります。ところが実はPostgreSQLのserialカラムのデフォルト値は、表面上は名前を指定して nextval
を呼び出す式として実装されているように見えても、中では内部IDによる参照として実現されているそうです。我々は表面的な名前を変更しているだけなので、実はそもそも参照側の追従は必要ないということがわかりました。ラッキーですね。
まとめ
Rails (ActiveRecord) の把握しているスキーマ定義と、実際の本番DBのスキーマ定義に乖離ができてしまい、Rails側からうまく扱いづらくなっている状況を、トリッキーなマイグレーションを書くことで解消してみました。ちょっとした開発上の負債の解消に過ぎませんが、これによってスキーマ定義のあるべき状態が明確になることには一定の意義があると思います。