はじめに
DB設計を担当することになり、テーブルを正規化して設計したあと、その変更をFlywayで流していく作業をしました。
テーブルを作るマイグレーション(V__)は、なんとなく分かって流せました。でもそのあと、初めてビューを書いたり、操作履歴用のトリガーを書いたりする段になって、いろいろ分かっていないことに気づきました。そもそも V と R の違いも、R がどういう仕組みなのかもふわっとしたままで、トリガーにいたっては最初 V に入れて詰まる、という失敗もしました。
この記事は、そこでつまずいたことを自分なりに整理したメモです。DDL・V/R・ビュー・トリガーは、バラバラの話に見えて「FlywayでDBの変更を管理する」という1本の筋でつながっていた、というのが自分の中での一番の発見でした。
初心者の学習メモです(使っているDBはPostgreSQLです)。誤りがあればご指摘ください。
対象読者
- Flywayを使い始めて、
V__とR__の使い分けに迷っている人 - ビューやトリガーをFlywayでどう管理するのか分からない人
- 「DDL」と言われてもピンとこない人
前提:FlywayとDDL
Flywayは、DBの変更を「マイグレーションファイル」という単位で管理し、順番に適用してくれるバージョン管理ツールです。DBに対する変更をGitのように積み上げていけます。
そのファイルの中に書くのは、基本的に DDL です。DDLは「テーブルなどの入れ物を作る・変えるSQL」で、CREATE TABLE、ALTER TABLE、CREATE VIEW などが該当します(対して、中身のデータを扱う INSERT / SELECT などは DML と呼びます)。
Flywayで管理するのは、主にこのDDLの積み重ねだと考えると分かりやすいです。
DDLの基本:CREATE TABLE を書く
Flywayの V__ ファイルに実際に書くのは、多くの場合この CREATE TABLE です。正規化して設計したテーブルを、DDLとして書き起こすとどうなるか、制約込みで見ていきます。
たとえば「ユーザー」と「注文」のテーブルを作るとします。
-- V1__create_users.sql
CREATE TABLE users (
id SERIAL PRIMARY KEY, -- 主キー(PK)。SERIALで自動採番
email VARCHAR(255) NOT NULL UNIQUE, -- 必須 & 重複禁止(UK)
name VARCHAR(100) NOT NULL, -- 必須
created_at TIMESTAMP NOT NULL DEFAULT now() -- 作成日時。既定値を設定
);
-- V2__create_orders.sql
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL, -- どのユーザーの注文か
amount INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) -- 外部キー(FK)
);
1行ずつ、制約の意味を整理します。
-
PRIMARY KEY(主キー / PK):その行を一意に識別する列。テーブルに1つ。SERIALを付けると、PostgreSQLが自動で連番を振ってくれます。 -
NOT NULL:空(NULL)を禁止する。必ず値が入る列に付けます。 -
UNIQUE(一意キー / UK):値の重複を禁止する。上のemailは「必須(NOT NULL)かつ重複禁止(UNIQUE)」を両方付けています。 -
FOREIGN KEY ... REFERENCES(外部キー / FK):別テーブルを指す列。orders.user_idはusers.idに実在する値しか入れられなくなり、存在しないユーザーの注文が作れなくなります(=データのつながりが守られる)。 -
DEFAULT:値を省略したときの既定値。created_atは挿入時に自動で現在時刻が入ります。
SERIAL はPostgreSQL特有の書き方です(自動採番のための型)。MySQLなら AUTO_INCREMENT、と書き方が変わります。この記事はPostgreSQL前提です。
こうして書いた CREATE TABLE を V1__, V2__ と積み上げていくのが、Flywayでのテーブル管理の基本になります。では、その V と、あとで出てくる R は何が違うのか——ここからが本題です。
本題:V と R の違い
Flywayのマイグレーションファイルは、ファイル名の先頭文字で種類が決まります。ここが今回一番つまずいたところです。
V(Versioned)=1回だけ実行される
V1__create_users.sql のように V で始まるファイル。バージョン付きマイグレーションで、一度適用されたら二度と実行されません。
テーブルを作る、カラムを足すといった「積み上げていく変更」に使います。過去の変更履歴として順番に積まれていくイメージです。さきほどの CREATE TABLE は、まさにこの V として積んでいきます。
-- V1__create_users.sql(前節で書いたもの)
CREATE TABLE users ( ... );
R(Repeatable)=中身が変わるたびに再実行される
R__create_user_view.sql のように R で始まり、バージョン番号を持たないファイル。繰り返しマイグレーションで、ファイルの中身が変わると、そのたびに再実行されます。
ここが自分は分かっていませんでした。V は1回きりなのに、R はファイルを書き換えるとまた走る。この違いが、あとで説明するビューの管理にちょうど効いてきます。
ファイル名の違い
V はバージョン番号あり(V1__, V2__…)。R はバージョン番号なし(R__)。最初、R にも番号を付けるのかで迷いましたが、付けません。
適用される順番
Flywayは、まず未適用の V をバージョン順に適用し、そのあとで R を適用します。R は、前回から中身が変わったもの(チェックサムが変化したもの)だけが再適用されます。
なぜビューは R で管理するのか
ここで、今回自分が書いたビューの話につながります。
テーブルは「積み上げる」もの(一度作ったら、変更はALTERで足していく)なので V が向きます。一方でビューは「作り直してよい」ものです。定義を変えたいときは、丸ごと定義し直せばいい。
PostgreSQLには CREATE OR REPLACE VIEW という書き方があり、「無ければ作る、あれば置き換える」を1文でやってくれます。これは何度実行しても同じ結果になるので、まさに「変わるたびに再実行される」R と相性が抜群です。
-- R__user_summary_view.sql
CREATE OR REPLACE VIEW user_summary AS
SELECT
u.id,
u.name,
o.order_count
FROM users u
LEFT JOIN (
SELECT user_id, COUNT(*) AS order_count
FROM orders
GROUP BY user_id
) o ON o.user_id = u.id;
ビューの定義を直したくなったら、この R__ ファイルを書き換えるだけ。次のFlyway実行時に、中身が変わったと検知されて自動で再適用されます。V でこれをやろうとすると、1回きりなので後から直せず、詰みます。
ビューを CREATE VIEW(OR REPLACE なし)で書くと、2回目の実行で「既に存在する」とエラーになります。R は再実行される前提なので、CREATE OR REPLACE VIEW で書くのがポイントです。
そもそもビューとは(実テーブルとの違い)
ビューを初めて書いたとき、「これは実テーブルと何が違うの?」がピンと来ていませんでした。整理するとこうです。
| 実テーブル | ビュー | |
|---|---|---|
| データ | 実際に持っている | 持たない(元テーブルを参照するだけ) |
| 中身 | 行そのもの | 保存された SELECT 文 |
| 使うとき | そのまま読む | 参照するたびに SELECT が実行される |
ビューは、保存された SELECT 文です。それ自体はデータを持たず、参照されるたびに元テーブルから計算して見せる「仮想的なテーブル」です。
自分が書いたのは、次の2種類でした。
1. 複数テーブルをJOINしてまとめるビュー
正規化でテーブルを分割したので、「まとめて見たい」ときに毎回JOINが必要になります。そのJOINをビューに閉じ込めておくと、使う側は普通のテーブルのように SELECT * FROM user_summary で読めます。正規化で分けたものを、見るときに再結合するという使い方で、正規化とビューが裏表の関係になっていると感じました。
2. 元データは1か所、見せ方を複数作るビュー
ステータスによって表示が変わる画面が2つあり、それぞれにビューを作りました。ここで大事なのは、ビューがステータスを持っているわけではないことです。
ステータスの実体は元テーブルに1か所だけあります。そこを更新すると、それを参照している2つのビューの表示が、両方いっしょに変わります。「完了」に変えれば、2画面とも連動して切り替わる。
これはまさにビューの利点で、データを1か所で持ちつつ(=二重管理しない)、見せ方だけ複数用意できる。正規化の「重複と矛盾をなくす」思想と、根っこは同じだと気づきました。
ビューは基本的に読み取り用と考えると、まずは理解しやすいです。データの実体は元テーブルにあるので、「見せ方」を作るものと捉えると混乱しません。(厳密には、単純なビューや INSTEAD OF トリガーを使ったビューは更新できる場合もありますが、最初は「読み取り用」と考えて差し支えありません。)
トリガー:操作履歴を自動で記録する
ここまでのビューとは別の話として、トリガーも使いました。トリガーはFlywayの管理単位(V/R)の話ではなく、DB自体が持つ機能です。今回、テーブルへの変更を記録する操作履歴を、このトリガーで自動化しました。
トリガーは、テーブルに対して INSERT / UPDATE / DELETE が起きたときに、自動で処理を走らせる仕組みです。アプリ側で毎回「変更したらログも書く」と書かなくても、DB側が勝手に履歴を残してくれます。操作履歴や監査ログは、書き漏らすと意味がないので、アプリ任せにせずDBで確実に記録できるトリガーが向いています。
まず、履歴を貯めるテーブルを用意します。これは普通のテーブルなので、V で作ります(トリガーやビューではなく、積み上げるDDLなので V)。
-- V3__create_users_history.sql
CREATE TABLE users_history (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL, -- どのユーザーの変更か
operation VARCHAR(10) NOT NULL, -- 'INSERT' / 'UPDATE' / 'DELETE'
changed_at TIMESTAMP NOT NULL DEFAULT now() -- いつ変更されたか
);
そのうえで、PostgreSQLでは「実際の処理を書いた関数」と「それをいつ呼ぶかを決めるトリガー」の2つを作ります。
-- 履歴を記録するトリガー(関数+トリガー本体)
-- 1. 履歴を書き込む処理(関数)
CREATE OR REPLACE FUNCTION log_users_change() RETURNS TRIGGER AS $$
BEGIN
INSERT INTO users_history (user_id, operation, changed_at)
VALUES (
COALESCE(NEW.id, OLD.id), -- INSERT/UPDATEはNEW、DELETEはOLD
TG_OP, -- 'INSERT' / 'UPDATE' / 'DELETE' が入る
now()
);
RETURN NULL; -- AFTERトリガーなので戻り値は使われない
END;
$$ LANGUAGE plpgsql;
-- 2. いつ呼ぶかを決める(usersの変更後に上の関数を実行)
DROP TRIGGER IF EXISTS trg_users_audit ON users;
CREATE TRIGGER trg_users_audit
AFTER INSERT OR UPDATE OR DELETE ON users
FOR EACH ROW EXECUTE FUNCTION log_users_change();
ポイントをいくつか。
-
AFTERを使う:ログ記録は、変更が確定した後に残すのがセオリーです。BEFORE(変更前)にすると、その後の処理が失敗して変更が取り消された場合でも、ログだけ残ってしまうことがあります。「実際に変わったことだけ記録する」ためにAFTER。 -
NEWとOLD:NEWは変更後の行、OLDは変更前の行。INSERTにはNEWしか、DELETEにはOLDしか無いので、COALESCEで両対応にしています。 -
TG_OP:どの操作(INSERT/UPDATE/DELETE)で起動したかが入る変数。これで「何をされたか」を履歴に残せます。
関数もトリガーも CREATE OR REPLACE / DROP ... IF EXISTS で書いておくと、同じスクリプトを何度実行しても失敗しません。Flywayで管理する場合にどのファイル(V/R)へ置くかはチームの方針によりますが、書き方としては「何度流しても大丈夫」にしておくと安全です。
体験談:最初Vに入れて、Rに移した
このトリガー、自分は最初 V に入れて流しました。テーブルと同じ「1回流す変更」のつもりだったからです。
ところが後で、記録する内容(トリガーの定義)を変えたくなりました。ここで詰まりました。適用済みの V は書き換えられない(書き換えるとチェックサム不一致でFlywayが失敗する)ので、直せなかったのです。
そこで R に移しました。R は中身を変えるたびに再実行されるので、トリガーの定義を直したくなったら同じファイルを書き換えるだけで済みます。CREATE OR REPLACE FUNCTION や DROP TRIGGER IF EXISTS と組み合わせておけば、何度でも作り直せます。
この経験で、V/Rの使い分けが腹落ちしました。テーブルのように積み上げる変更は V、トリガーやビューのように"あとで作り直したくなるもの"は R が向いている、と。トリガー自体はV/RどちらのファイルでもDBに作れますが、「作り直しやすさ」で選ぶならRだ、というのを身をもって学びました。
トリガーは「DBが裏で勝手に動く」ため、便利な反面、追いづらいという側面もあります。アプリのコードには現れないのに履歴が書かれるので、知らないと「これどこで記録してるの?」となります。監査ログのように"確実性が最優先で、ロジックが単純"な用途に向いていて、複雑な業務ロジックをトリガーに詰め込むのは避けるのが無難、と感じました。
Rファイルが増えてきたら:分割と実行順
開発を進めると、R で管理するもの(関数・ビュー・トリガー)がどんどん増えます。最初は1つのファイルにまとめて書いていたのですが、行数が膨らむと目当てのビューやトリガーがファイルの中で埋もれて、探すのも変更のレビューも大変になってきました。
そこで、種類ごとにファイルを分けました。
R__1_functions.sql -- 関数
R__2_views.sql -- ビュー
R__3_triggers.sql -- トリガー
分けるときに一番気をつけたのが実行順です。Repeatableファイルは名前順(番号順)に実行されるので、依存する側を後にしないと失敗します。
-
関数 → トリガー:トリガーは関数を呼び出すので、関数が先に作られていないとトリガー作成でエラーになります。だから
1_functionsを先、3_triggersを後に。 -
ビューがビューを参照している場合:あるビューが別のビューを
SELECTしている場合、参照される側が先に作られている必要があります。同じ2_views.sqlの中でも、記述する順番を「先に作る側 → 後に使う側」にします。
Repeatableファイルは名前順に実行されます。なので依存関係があるもの(関数→トリガー、ビューA→ビューB)は、番号や記述順で「先に作る側」が前に来るようにそろえるのがポイントです。番号プレフィックス(1_, 2_, 3_)は、この順番を明示するために付けています。
ファイルを「1オブジェクト1ファイル」まで細かく分ける手もありますが、その分ファイル数が増えます。今回の規模(各十数本)だと、種類ごとの3ファイルがちょうどよいバランスでした。特定のオブジェクトだけ変更が頻繁になったら、そのとき個別に切り出す、くらいの温度感で十分だと感じています。
実装して分かった:DDLの変更は手戻りが大きい
実装を進める中で、一度作ったテーブルの構造を大きく変えたくなる場面がありました。列を足す、いらない列を削る、型を変える——やってみると、これがなかなか大変でした。
理由1:適用済みの V は書き換えられない
一度流した V1__create_users.sql を後から直したくなっても、Flywayは適用済みファイルの書き換えをチェックサムで検知して失敗させます。なので「あの定義、やっぱり変えたい」と思っても、元のファイルは触れず、新しい V4__alter_users.sql を積んで直すことになります。設計の迷いがそのまま履歴として残っていく感覚でした。
-- V4__alter_users.sql(後から列を足す例)
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
理由2:DDLは土台なので、変えると上に乗ったものが連鎖で壊れる
テーブル定義は一番下の土台です。列を削ったり型を変えたりすると、その列を参照しているビュー、他テーブルのFK、アプリのコードが軒並み影響を受けます。1か所の変更が、思った以上に広く波及する。「大きく変える」ほど、この連鎖で手戻りが膨らみました。
そこで実感したこと:設計を先に固めておくのが一番効く
結局、手戻りを減らす一番の方法は、DDLを流す前に設計(正規化)をしっかり固めることでした。テーブルの形を正規化で確定させ、PK/UK/FKを決めてから V を流す。逆に、見切り発車で V を積むと、あとから ALTER の山になります。「設計を先に」とよく言われる理由を、手戻りを通して身をもって理解しました。
もちろん、運用を続ければ列の追加などは避けられません。大事なのは「最初に決められることは決めておく」ことで、初期の作り込み不足による手戻りと、運用上の自然な変更を分けて考えるようになりました。
つまずきポイントまとめ
-
Vは1回きり、適用済みの中身は変えてはいけない:適用済みのVファイルを後から書き換えると、Flywayがチェックサムの不一致を検知して失敗します。直したいときは新しいVを足す。 -
ビューやプロシージャは
Rで管理する:作り直してよいものはR。Vに入れると後から直せない。 -
ビューは
CREATE OR REPLACE VIEWで書く:Rは再実行されるので、何度流しても大丈夫な書き方にする。 -
トリガーはDBの機能(V/Rとは別の話):操作履歴などを自動記録できる。ログ記録は変更確定後の
AFTERがセオリー。スクリプトはDROP ... IF EXISTS/CREATE OR REPLACE FUNCTIONで、何度流しても平気な形にしておく。 - ビューはデータを持たない:ステータスなどの実体は元テーブル。ビューは見せ方。
-
Rファイルは名前順に実行される:関数・ビュー・トリガーを分けるときは、依存する側を後に(
1_functions→2_views→3_triggers)。ビュー同士に依存があれば記述順にも注意。
おわりに
最初は「V/R」「DDL」「ビュー」「トリガー」を、それぞれ別の分からないこととして眺めていました。でも整理してみると、DDLをFlywayで管理し(積み上げる変更はV、作り直してよいビューなどはR)、さらにトリガーのようなDBの機能も組み合わせて、DBの動きを作っていく——という全体像が見えてきました。V/Rはあくまで「管理のしかた」、ビューやトリガーは「DBの機能」で、レイヤーが違うと分かったのも整理になりました。
初めてビューを書いたこと、トリガーを最初 V に入れて詰まり R に移したこと、そしてDDLの変更で手戻りを経験したこと——これらを通して、点だったものが線になった感覚があります。特に「作り直したいものは R」「設計(正規化)を先に固めておくと、後の手戻りが減る」というのは、実装して初めて腹落ちした学びでした。同じところで戸惑っている人の参考になれば嬉しいです。
参考
- Migrations(Flyway公式ドキュメント / Redgate) ― V/Rの違いと適用順(Versionedが先、Repeatableは最後に説明順)の一次情報
- データベースマイグレーションツール、Flywayを試してみる(CLOVER🍀) ― 実際に動かした結果つきでV/Rを解説
- FlywayのdocumentationのConceptsを読んだ(kagamihogeの日記) ― Repeatableの再適用の仕組みと命名規則(Prefix/Version/Separator/Description)の整理
- 更新可能なビューの作成方法(PostgreSQL) ― ビューが更新可能になる条件・ならない条件(JOINやGROUP BY等)と、更新可否の確認方法