メインコンテンツまでスキップ

テーブル設計とマイグレーション

前の章で起動したアプリは、PostgreSQL につながっていても、テーブルを 1 つも持っていません。この章では、注文 API が使う 4 つのテーブル (商品・在庫・注文・注文明細) を設計し、マイグレーション のファイルとしてサンプルリポジトリに置きます。マイグレーションは、データベースの構造を変える SQL を、順番の付いたファイルとして管理する仕組みです。アプリを起動すると、まだ当てていないファイルを Flyway が順に当てます。章の後半では、開発の DB とテストの DB の違いと、手元で試すための初期データの入れ方を扱います。

この章のコードは tag api-08 にあります。前の章と同じく、git fetch --tags と git switch --detach api-08 で取り出してください。

Flyway のマイグレーション​

Flyway は、app/src/main/resources/db/migration/ に置いた SQL のファイルを、名前の頭の版の番号の順に当てます。ファイルの名前は V<版の番号>__<説明>.sql の形で、V の後の番号と、2 つ続けた _ の後の説明でできています。

サンプルリポジトリには、第 1 部の時点から V1__baseline.sql が置いてあります。中身はコメントだけで、何もしません。この章で、テーブルを作る V2__create_tables.sql を足しました。

Flyway は、当てたファイルを、データベースの中の flyway_schema_history というテーブルに記録します。アプリを起動するたびに、db/migration/ のファイルとこの記録を突き合わせ、記録に無いファイルだけを当てます。前の章のコードで起動した手元の DB には V1 が記録されているので、この章のコードで起動すると、V1 は当たらず V2 から当たります。

記録にはファイルの中身から計算した値 (チェックサム) も残ります。当てた後のファイルを書き換えると、起動の時点で記録の値と合わなくなり、Flyway はアプリの起動を止めます。

起動のログ (抜粋)
Migration checksum mismatch for migration version 2
-> Applied to database : -586980501
-> Resolved locally : -125497136

当てたファイルは、手元の DB だけでなく、ほかの人の DB や本番の DB にも当たっています。書き換えた内容は、どの DB にも当たりません。テーブルの構造を変えたいときは、変更を新しい版の番号のファイルとして足します。

テーブルを設計する​

テーブルを置くデータベースは PostgreSQL です。PostgreSQL は、SQL を使い、それを拡張するオープンソースのデータベースです1。このガイドは版 18 を使います (app/compose.yaml の image とテストの TestcontainersConfiguration の image が、どちらも postgres:18)。この章のテーブルは、PostgreSQL の型のうち、主キーの uuid、日時の timestamp with time zone、整数の integer と bigint を使い、列の値の範囲を check 制約で決めます。

テーブルは、はじめに の図の 4 つの概念に 1 つずつ対応します。

商品と在庫​

app/src/main/resources/db/migration/V2__create_tables.sql (抜粋)
create table products (
id uuid primary key,
name varchar(100) not null,
price integer not null check (price >= 0),
tax_rate integer not null check (tax_rate in (8, 10)),
version bigint not null
);

-- 在庫は商品ごとに 1 行。主キーを商品の ID にする。
create table inventories (
product_id uuid primary key references products (id),
quantity integer not null check (quantity >= 0),
version bigint not null
);

主キーは UUID にしています。データベースが番号を振る連番でなく、アプリが ID を作ってから行を入れます。行を入れる前に ID が決まるので、注文を作る処理の途中で、データベースに問い合わせずに ID を使えます。どの形の UUID を、どうやって作るかは次の章で扱います。

価格は円の整数 で持ちます。このガイドの価格は、消費税を含んだ金額です。double のような浮動小数点の型は、0.1 のような値を正確に表せないので、金額には使いません。円は 1 円より細かい単位を持たないので、整数で足ります。

税率は 8 か 10 の整数 で、パーセントを表します。標準税率と軽減税率の 2 つだけなので、check の制約でほかの値を入れられないようにしています。

在庫の主キーは商品の ID です。在庫は商品ごとに 1 行だけなので、別の ID を振らずに、商品の ID をそのまま主キーにしています。quantity の check (quantity >= 0) は、在庫が負の数になる書き込みを、データベースの側でも止めます。

version の列 は、行を書き換えるたびにアプリが 1 つ増やす数です。次の章で使う Spring Data JDBC は、この列を、保存する行が新しい行か既にある行かを見分けるのに使います。2 人が同じ行を同時に書き換えたときに、後から書いた側を失敗させる用途 (楽観ロック) にも使います。

注文と注文明細​

app/src/main/resources/db/migration/V2__create_tables.sql (抜粋)
create table orders (
id uuid primary key,
contact_email varchar(254) not null,
status varchar(20) not null check (status in ('PENDING', 'CANCELLED')),
ordered_at timestamp with time zone not null,
version bigint not null
);

/*
* 注文明細は注文の一部なので、注文の ID と注文の中の位置 (0 から) を主キーにする。
* 単価と税率は、注文した時点の商品の値を写す。後で税率が変わっても注文の行は変えないので、
* 商品の側の税率の制約 (8 か 10) をここには付けない。
*/
create table order_lines (
order_id uuid not null references orders (id),
line_index integer not null,
product_id uuid not null references products (id),
quantity integer not null check (quantity > 0),
unit_price integer not null check (unit_price >= 0),
tax_rate integer not null check (tax_rate >= 0),
primary key (order_id, line_index),
unique (order_id, product_id)
);

contact_email は連絡先メールアドレスです。このガイドのアプリにはまだ会員の仕組みが無いので、注文の持ち主を表す情報はこの列だけです。

status は注文の状態で、受付 (PENDING) と取消 (CANCELLED) の 2 つです。状態の名前は文字列で持ちます。check で 2 つの名前に限っているので、綴りを誤った状態は入りません。

ordered_at は注文を受け付けた日時で、timestamp with time zone の型にしています。PostgreSQL はこの型の値を、タイムゾーンを揃えた時刻として持ちます。どの地域のサーバーで書き込んでも、同じ瞬間は同じ値になります。

注文明細は、注文から切り離して扱うことの無い、注文の一部です。そのため独自の ID を持たせず、注文の ID と、注文の中の何番目の明細か (line_index) の組を主キーにしています。unique (order_id, product_id) は、1 つの注文に同じ商品の明細が 2 行入ることを止めます。

unit_price (単価) と tax_rate は、注文した時点の商品の価格と税率を写したものです。商品の価格や税率が後で変わっても、受け付けた注文の金額は変わりません。この 2 つの列が商品の行を参照せずに値を持つのはそのためです。税率の check を商品の側より緩くしているのも同じ理由です。将来、税率の制度が変わって商品の側の制約を書き換えても、過去の注文の行は当時の税率のまま残ります。

開発の DB とテストの DB​

このガイドのアプリが使う PostgreSQL は 2 つあります。

開発の DBテストの DB
立てる仕組みDocker Compose (app/compose.yaml)Testcontainers (TestcontainersConfiguration)
立てる時点bootRun の起動テストの実行
データアプリを止めても残るテストを動かすたびに空の DB から始まる

テストの DB は、テストのコードの側で立てます。

app/src/test/kotlin/com/example/shop/TestcontainersConfiguration.kt (抜粋)
@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfiguration {

@Bean
@ServiceConnection
fun postgresContainer(): PostgreSQLContainer =
// latest にすると、新しいメジャー版が出た時点でテストの前提が黙って変わる。メジャー版で固定する。
PostgreSQLContainer(DockerImageName.parse("postgres:18"))
}

@ServiceConnection を付けた bean は、前の章の Docker Compose と同じく、立てたコンテナの接続先を Spring Boot に渡します。テストが始まるときに新しいコンテナを立てるので、テストはいつも、マイグレーションだけを当てた空の DB から始まります。

2 つの DB は、どちらも postgres:18 の image を使い、同じマイグレーションのファイルを当てます。テストが通った構造と、手元で動かす構造が食い違いません。

違うのはデータの残り方です。開発の DB は、前の章で見たとおり、アプリを止めてもコンテナが残り、データも残ります。手元で試した注文は、次に起動したときも残っています。

手元とテストにだけ入れる初期データ​

注文 API を手元で試すには、注文できる商品が要ります。その商品の行を、初期データ として別のディレクトリ app/src/main/resources/db/seed/ に置きます。

app/src/main/resources/db/seed/V3__insert_products.sql
/*
* 手元で動かすときと、記事の例を照合するテストで使う初期データ。本番には入れない (application-dev.yaml)。
* 版の番号は db/migration と 1 つの並びで付ける。
* ID は記事の curl の例が指すので、固定の値にする。
*/
insert into products (id, name, price, tax_rate, version) values
('0199f3a8-0000-7000-8000-000000000001', 'ノート', 330, 10, 0),
('0199f3a8-0000-7000-8000-000000000002', 'ボールペン', 150, 10, 0),
('0199f3a8-0000-7000-8000-000000000003', '付箋', 220, 10, 0),
('0199f3a8-0000-7000-8000-000000000004', 'マーカー', 880, 10, 0),
('0199f3a8-0000-7000-8000-000000000005', 'コーヒー豆', 1280, 8, 0),
('0199f3a8-0000-7000-8000-000000000006', '緑茶', 540, 8, 0);

insert into inventories (product_id, quantity, version) values
('0199f3a8-0000-7000-8000-000000000001', 50, 0),
('0199f3a8-0000-7000-8000-000000000002', 100, 0),
('0199f3a8-0000-7000-8000-000000000003', 30, 0),
('0199f3a8-0000-7000-8000-000000000004', 3, 0),
('0199f3a8-0000-7000-8000-000000000005', 20, 0),
('0199f3a8-0000-7000-8000-000000000006', 0, 0);

商品の ID を固定の値にしているのは、後の章の curl の例がこの ID を指すためです。読者の手元の DB にも同じ ID の商品が入るので、記事の curl をそのまま打てます。

このディレクトリを Flyway が読むのは、dev の profile のときだけです。

app/src/main/resources/application-dev.yaml
spring:
flyway:
# 手元では、テーブルを作るマイグレーションに加えて初期データ (db/seed) を当てる。本番は db/migration だけ。
locations: classpath:db/migration,classpath:db/seed
jackson:
serialization:
indent-output: true

spring.flyway.locations は、Flyway がマイグレーションのファイルを探す場所です。既定は classpath:db/migration だけで、本番の起動はこの既定のまま初期データを当てません。dev の profile は bootRun のときに有効になるので、手元で起動したアプリには初期データが入ります。記事の curl の例を照合するテスト (最初のエンドポイントの章で作ります) も、同じ dev の profile で動かします。

初期データのファイルの版の番号は、db/migration のファイルと 1 つの並びで付けています。初期データを V1000 のような離れた番号にすると、手元の DB はその番号まで当てた状態になります。その後でテーブルを変える V4 を足すと、Flyway は V4 を「当てた番号より古いのに当たっていないファイル」として扱い、起動を止めます。

起動のログ (抜粋)
Detected resolved migration not applied to database: 4.
To ignore this migration, set -ignoreMigrationPatterns='*:ignored'. To allow executing this migration, set -outOfOrder=true.

1 つの並びにしておけば、本番は V3 を飛ばして V1・V2・V4 を当て、手元は V1 から V4 までを順に当てます。どちらでも、新しく足したファイルが当てた番号より後ろに来ます。

手元の DB を確かめる、戻す​

この章のコードで ./gradlew :app:bootRun を打つと、起動のログに Flyway が当てたファイルの数が出ます。前の章のコードで一度起動した手元の DB では V2 と V3 が、初めて起動する DB では V1 から V3 が当たります。

テーブルの中身は、コンテナの中の psql で見られます。app/ のディレクトリで次のコマンドを打ってください。

docker compose exec postgres psql -U shop -d shop -c 'select name, price, tax_rate from products'

docker compose exec postgres は、compose.yaml の postgres のコンテナの中でコマンドを動かします。アプリを止めていても、コンテナは止まっているだけなので、docker compose start で動かしてから打ってください。

手元の DB を初期データだけの状態に戻したいときは、アプリを止めてから、app/ のディレクトリで次のコマンドを打ちます。

docker compose down --volumes

down はコンテナを消し、--volumes はデータを置いていた領域も消します。次に bootRun で起動すると、新しいコンテナが立ち、マイグレーションと初期データが最初から当たります。後の章の curl の例は、初期データだけの状態から順に打つことを前提にしています。前の章で試した注文が残っていると、在庫の数などが記事と違って見えます。そのときは、この手順で戻してから打ち直してください。

本番で効く注意点​

当てたマイグレーションを書き換えない​

本番の DB に当てたマイグレーションのファイルを書き換えると、次の起動のときにチェックサムが合わず、アプリは起動しません。誤りを直すときは、直す内容を新しい版のファイルとして足します。手元でまだ commit していないファイルなら書き換えてかまいませんが、手元の DB に一度当てていれば、前の節の手順で DB を戻す必要があります。

本番で clean を有効にしない​

Flyway には、DB の中のテーブルをすべて消す clean の操作があります。Spring Boot は既定で spring.flyway.clean-disabled を true にして、この操作を止めています。テストで DB を空に戻すために clean を使うことはありますが、その設定はテストの側にだけ書き、本番で読む設定ファイルには書かないでください。

初期データを本番に当てない​

初期データの商品は、手元とテストのためのものです。spring.flyway.locations を application.yaml に書くと、本番でも初期データが当たり、固定の ID の商品が本番の DB に入ります。初期データのディレクトリを足す設定は、application-dev.yaml のように、本番で有効にならない profile のファイルにだけ書きます。

まとめ​

  • Flyway は db/migration/ の V<版の番号>__<説明>.sql を番号の順に当て、当てたファイルを flyway_schema_history に記録します
  • 当てたファイルを書き換えると、チェックサムが合わずに起動が止まります。構造の変更は新しい版のファイルで足します
  • 主キーは、アプリが作る UUID です。version の列は、新しい行かの見分けと楽観ロックに使います
  • 金額は円の整数で持ちます。注文明細は、注文した時点の単価と税率を写して持ちます
  • 開発の DB はデータが残り、テストの DB はテストのたびに空から始まります。どちらも同じ image と同じマイグレーションを使います
  • 初期データは db/seed/ に置き、dev の profile のときだけ当てます。版の番号は db/migration と 1 つの並びで付けます

次に読む​

前の章は Spring Boot の骨格 です。

次章 Spring Data JDBC で読み書きする では、この章のテーブルを Kotlin のクラスに対応付け、注文を保存して読み戻します。最初のテストもそこで書きます。

練習問題​

商品に説明 (description) の列を足すことになりました。手元の DB にも本番の DB にも、この章のマイグレーションは当たっています。どのファイルをどう変えますか

解答例

V2__create_tables.sql には手を付けず、新しいファイル V4__add_product_description.sql を db/migration/ に足します。

V4__add_product_description.sql
-- 既にある行には説明が無いので、null を許す列として足す。
alter table products add column description varchar(500);

V2 を書き換えると、当てたファイルのチェックサムが合わなくなり、手元と本番のどちらでも起動が止まります。番号は、初期データの V3 の次の 4 にします。

列には not null を付けていません。既にある商品の行は説明を持たないので、not null の列を足そうとすると、その行が制約を満たさず、マイグレーションが失敗します。説明を必ず持たせたいなら、列を足すファイルで既定の値を決めるか、既にある行に値を入れてから制約を足す、という順で書きます。

注文明細の tax_rate には check (tax_rate >= 0) しか付けておらず、商品の tax_rate のように 8 と 10 に限っていません。なぜですか

解答例

注文明細の税率は、注文した時点の税率を写した記録だからです。

商品の税率の制約は、いま使える税率を表しています。税率の制度が変わって、たとえば軽減税率が無くなれば、商品の側の制約は新しいマイグレーションで書き換えます。注文明細に同じ制約を付けていると、そのマイグレーションは、過去の注文の行が新しい制約を満たさないために失敗するか、過去の行を書き換えることになります。過去の注文の金額に要るのは、注文した時点の税率です。

そのため、注文明細の側には、どの時点でも成り立つ「負でない」ことだけを制約にしています。単価の unit_price を商品の price から写して持つのも、同じ理由です。


Footnotes​

  1. 出典: About (PostgreSQL)。該当の文は "PostgreSQL is a powerful, open source object-relational database system that uses and extends the SQL language"。 ↩