---
note: 履歴・有効期間(tstzrange+排他制約)・状態遷移・論理削除・非正規化の実務モデリング判断
created: 2026-08-14T10:00:00+09:00
---

# 第6回｜実務のモデリング

> 履歴・状態・削除・多対多・非正規化——正規化の先にある「実務の判断」を身につける回である。

## この回のねらい

第4回・第5回で正規化と型・制約の基礎を身につけた。この回では、それだけでは決まらない実務上の判断を扱う。価格改定のような「時間で変わる値」をどう持つか、注文ステータスのような「決まった経路でしか変わらない値」をどう守るか、削除や非正規化をいつ選びいつ避けるか、という4つのテーマを、共通スキーマの`product_prices`と`orders`を素材に具体的なSQLで詰める。

## 到達目標

- 履歴のある値の持ち方として、valid_from/valid_to方式とtstzrange方式を比較して選べる
- `product_prices`にtstzrange + `EXCLUDE USING gist`で、有効期間が重複しない制約を実装できる
- 「ある日時の価格」を引くクエリを2通りの書き方で書き、両者の対応関係を説明できる
- CHECK制約だけでは注文ステータスの遷移を守れない理由を説明できる
- トリガまたはUPDATE文のWHERE句で、不正な状態遷移を防ぐ設計を実装できる
- 論理削除の「伝染コスト」を説明し、部分インデックス・ビューで緩和できる
- 非正規化した集計列の整合性維持コストを、トリガ方式とバッチ方式で比較して語れる

## 前提と準備

第1回で構築したスキーマ（`customers` / `categories` / `products` / `product_prices` / `orders` / `order_items` / `events`）にデータが投入済みであることを前提とする。本回では有効期間の重複を防ぐために`EXCLUDE`制約を使い、その内部でbtree_gist拡張が必要になる。あらかじめ有効化しておく。

```bash
psql -d postshop -c "CREATE EXTENSION IF NOT EXISTS btree_gist;"
```

PostgreSQL 16系を前提とする。

## この回で新しく出てくる用語

履歴・状態・削除の実装で使う道具の名前を先に揃えておく。
回をまたいで使う語は[用語集](glossary.md)にもまとめている。

| 用語 | 一行での説明 |
|---|---|
| range型（`tstzrange`） | 「いつからいつまで」のような区間を1つの値として持つ型 |
| 排他制約（`EXCLUDE`） | 「この条件で衝突する行どうしは共存できない」をDBに強制させる制約 |
| `btree_gist` | GiST索引の中で `=` のような通常の比較演算子も使えるようにする拡張 |
| トリガ | 行の挿入・更新・削除に反応して、自動的に指定した関数を実行させる仕組み |
| PL/pgSQL | PostgreSQLの手続き型言語。トリガ関数などの中身を書くのに使う |
| ビュー | 名前を付けて保存した`SELECT`文。テーブルと同じように参照できる |
| 部分インデックス | 条件に合う行だけを載せた索引。対象が偏っているとき小さく速い |
| 論理削除 | 行を物理的に消さず、削除済みを表す列を立てて見えなくする設計 |
| 非正規化 | 正規化で分離した値を、意図的に重複させて持たせること |

## 本編

### 履歴を持つとはどういうことか

`product_prices`は商品の価格改定履歴を保持するテーブルである。`products.price`が「現在価格」の1点のスナップショットだとすれば、`product_prices`は「いつからいつまでいくらだったか」を区間として記録する。履歴テーブルが必要になる場面は、価格改定に限らない。契約プランの変更、住所変更履歴、ステータス変更ログなど、「時間軸で値が変わり、過去の値も参照したい」ものすべてに共通する設計課題である。

### 方式1: valid_from / valid_to（2列方式）

共通スキーマの`product_prices`はこの方式で定義されている。

```sql
CREATE TABLE product_prices (
  id          bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  product_id  bigint        NOT NULL REFERENCES products(id),
  price       numeric(10,2) NOT NULL CHECK (price >= 0),
  valid_from  timestamptz   NOT NULL,
  valid_to    timestamptz                              -- NULLは現在も有効
);
```

長所は列がシンプルなことである。ORMやフレームワークとの相性がよく、`valid_from`・`valid_to`という名前だけで意図が伝わる。短所は、「同じ商品について有効期間が重複しない」という制約を素のCHECK制約では表現できないことである。CHECK制約は挿入・更新される**その1行の値**しか見えず、他の行の`valid_from`・`valid_to`と比較できない。したがって、アプリのバグや同時実行の競合によって重複する有効期間のレコードが入り込んでも、DBはそれを自動的には拒否できない。

### 方式2: tstzrange（範囲型1列方式）

PostgreSQLのrange型は「区間」を1つの値として表現する型である。`tstzrange`は`timestamptz`の区間を表す組み込み型で、区間演算子（`&&`＝重なる、`@>`＝含む、など）がそのまま使える。

```sql
-- 発想を示すための独立設計。実データはproduct_pricesを使い続ける
CREATE TABLE product_price_periods (
  id           bigint    GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  product_id   bigint    NOT NULL REFERENCES products(id),
  price        numeric(10,2) NOT NULL CHECK (price >= 0),
  valid_period tstzrange NOT NULL
);
```

長所は、`EXCLUDE USING gist`と組み合わせることで「同じ商品の区間が重ならない」ことをDBレベルで保証できる点にある。短所は、range型に不慣れなメンバーには読みにくく、ORMのサポートが薄い場合があることである。

| 観点 | valid_from/valid_to | tstzrange |
|---|---|---|
| 重複防止をDBで保証 | 素のCHECKでは不可（トリガ等が別途必要） | `EXCLUDE USING gist`で可能 |
| 「ある時点の値」を引くクエリ | 2条件のANDで書く | `@>`演算子1つで書ける |
| 可読性・学習コスト | 低い（誰でも読める） | range型の知識が必要 |
| 有効な索引 | B-treeで部分的にカバー可能 | GiSTが範囲検索に強い |

実務では、**既存のvalid_from/valid_to列を残しつつ、その2列から生成列でtstzrangeを導出し、重複防止の保証だけをtstzrangeに担わせる**ハイブリッドが有力な選択肢になる。既存のクエリを書き換えずに、DBレベルの保証を後から足せるからである。

### product_pricesに重複しない制約を張る

`GENERATED ALWAYS AS ... STORED`で`valid_from`・`valid_to`から`tstzrange`の生成列を作り、そこに`EXCLUDE USING gist`を張る。

```sql
ALTER TABLE product_prices
  ADD COLUMN valid_period tstzrange
  GENERATED ALWAYS AS (tstzrange(valid_from, valid_to, '[)')) STORED;

ALTER TABLE product_prices
  ADD CONSTRAINT product_prices_no_overlap
  EXCLUDE USING gist (product_id WITH =, valid_period WITH &&);
```

`tstzrange(valid_from, valid_to, '[)')`は「`valid_from`以上、`valid_to`未満」の半開区間を作る。`valid_to`がNULLなら上限は無限大（＝現在も有効）として扱われる。`EXCLUDE USING gist (product_id WITH =, valid_period WITH &&)`は「同じ`product_id`かつ区間が重なる（`&&`）行の共存を許さない」という意味の排他制約である。`product_id`（bigint）の等価比較をGiSTインデックスの中で使うために、btree_gist拡張が必要になる（GiSTはもともと範囲・幾何型向けのインデックスで、素の等価比較演算子クラスを持たないため）。

重複するINSERTが実際に弾かれる様子を確認する。

```sql
-- 商品id=1の価格が2024-01-01から2024-07-01まで1000円だったとする
INSERT INTO product_prices (product_id, price, valid_from, valid_to)
VALUES (1, 1000, '2024-01-01', '2024-07-01');

-- 重なる期間で別の価格をINSERTしようとすると弾かれる
INSERT INTO product_prices (product_id, price, valid_from, valid_to)
VALUES (1, 1200, '2024-06-01', NULL);
```

```text
ERROR:  conflicting key value violates exclusion constraint "product_prices_no_overlap"
DETAIL:  Key (product_id, valid_period)=(1, ["2024-06-01 00:00:00+00",)) conflicts with
existing key (product_id, valid_period)=(1, ["2024-01-01 00:00:00+00","2024-07-01 00:00:00+00")).
```

（タイムゾーン表記はセッションの`timezone`設定に依存する。ここではUTCの例を示した。）

アプリのバグや同時実行の競合で価格改定が重複登録されても、DBが機械的に拒否する。この種の保証はアプリ層のバリデーションだけでは達成しづらい。バリデーションのSELECTとINSERTの間に別トランザクションが割り込むレース条件が起こり得るからである。

### ある日時の価格を引くクエリ

「ある時点tにおいて有効だった価格」を引く書き方は2通りある。

valid_from/valid_toの2条件で書く場合。

```sql
SELECT price
FROM product_prices
WHERE product_id = 1
  AND valid_from <= '2024-06-15'::timestamptz
  AND (valid_to IS NULL OR '2024-06-15'::timestamptz < valid_to);
```

tstzrange（生成列）の`@>`演算子で書く場合。

```sql
SELECT price
FROM product_prices
WHERE product_id = 1
  AND valid_period @> '2024-06-15'::timestamptz;
```

```text
 price
--------
 1000.00
(1 row)
```

`@>`は「範囲が値を含む」という演算子である。valid_from/valid_to方式の2条件と論理的に等価だが、読みやすさと、GiSTインデックスをそのまま使える点で有利になる。どちらを採用するかはチームの方針の問題であり、片方を選んだらクエリ全体で書き方を混在させないことが大切である。

### 状態遷移をどう守るか

`orders.status`は次の6状態を取り、遷移できる経路は共通スキーマの定義どおり決まっている。

```text
(start) --> pending --> paid --> shipped --> delivered   [終端]
             |           |
             |           +--> refunded                   [終端]
             +--> cancelled                              [終端]
```

同じことを遷移可否の表にすると次のようになる。

| 現在の状態 | 遷移先 | 可否 |
|---|---|---|
| pending | paid | ○ |
| pending | cancelled | ○ |
| pending | shipped | ×（pendingから直接shippedにはならない） |
| paid | shipped | ○ |
| paid | refunded | ○ |
| paid | pending | ×（後戻りしない） |
| shipped | delivered | ○ |
| shipped | cancelled | ×（発送後のキャンセルは別業務） |
| delivered | （どこへも） | ×（終端状態） |
| cancelled | （どこへも） | ×（終端状態） |
| refunded | （どこへも） | ×（終端状態） |

`delivered`・`cancelled`・`refunded`はそこから先に遷移しない終端状態である。

なぜCHECK制約だけでは守れないか。`CHECK (status IN ('pending','paid',...))`のように**取りうる値の集合**は制限できるが、「pendingからしかpaidになれない」という**遷移の可否**はUPDATE前後の値を比較しないと判定できない。CHECK制約は1行の値しか見ないため、これは表現の対象外になる。

これを守る方法は2つあり、実務では併用するのが基本になる。

**1. UPDATE文のWHERE句に現在状態を条件として入れる**

```sql
UPDATE orders SET status = 'shipped'
WHERE id = 12345 AND status = 'paid';
```

影響行数が0件なら、「その注文がそもそもpaidではなかった」か「他のトランザクションが先に状態を変えた」ことを意味する。PostgreSQLのUPDATEは対象行にロックをかけて処理するため、2つのトランザクションが同時に同じ注文をpaidから別の状態へ遷移させようとしても、先に確定した方だけが成功し、後発のUPDATEはコミット後にWHERE句を再評価して0件になる。アプリ側は影響行数を見て遷移の成否を判定する。

**2. トリガで遷移表を強制する**

トリガとは、指定したテーブルへの挿入・更新・削除に反応して、DBが自動的に指定の関数を実行する仕組みである。
更新前に割り込む`BEFORE`トリガでは、関数の中で更新前の行を`OLD`、更新後の行を`NEW`という名前で参照でき、
`RAISE EXCEPTION`で処理を中止できる。CHECK制約と違ってOLDとNEWを突き合わせられるため、遷移の可否を判定できる。

```sql
CREATE OR REPLACE FUNCTION check_order_status_transition()
RETURNS trigger AS $$
BEGIN
  IF OLD.status = NEW.status THEN
    RETURN NEW;  -- statusを変えない更新は許可
  END IF;

  IF (OLD.status, NEW.status) NOT IN (
    ('pending', 'paid'),
    ('pending', 'cancelled'),
    ('paid', 'shipped'),
    ('paid', 'refunded'),
    ('shipped', 'delivered')
  ) THEN
    RAISE EXCEPTION 'invalid order status transition: % -> %', OLD.status, NEW.status;
  END IF;

  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_order_status_transition
  BEFORE UPDATE OF status ON orders
  FOR EACH ROW
  WHEN (OLD.status IS DISTINCT FROM NEW.status)
  EXECUTE FUNCTION check_order_status_transition();
```

不正な遷移を試みるとエラーになる。

```sql
UPDATE orders SET status = 'delivered' WHERE id = 12345;  -- 現在pendingだったとする
```

```text
ERROR:  invalid order status transition: pending -> delivered
CONTEXT:  PL/pgSQL function check_order_status_transition() line 14 at RAISE
```

トリガは、アプリのコードパスを経由しない直接のSQL実行（バッチ処理、他チームのスクリプト、手動修正など）からもDBを守る。一方でアプリ層のバリデーションは、ユーザーへのエラーメッセージや業務フロー（在庫確保のタイミングなど）を担う。**トリガとアプリ層は代替ではなく併用が基本**であり、トリガは「最後の砦」、アプリ層は「業務ロジックの本体」と位置づけるとよい。

### 論理削除は「いつ使い、いつ避けるか」

論理削除は、行を物理的にDELETEせず`deleted_at timestamptz`列（NULL＝生存、非NULL＝削除済み）を立てて「見えなくする」設計である。

```sql
ALTER TABLE customers ADD COLUMN deleted_at timestamptz;
```

利点は明確である。他テーブルからの外部キー参照が残っていても壊れない（削除した顧客の`orders`履歴は参照可能なまま）。誤削除からの復旧が容易。監査証跡が残る。

一方で「伝染コスト」がある。`deleted_at`列を持つテーブルを参照する**すべてのSELECT・JOIN**に`WHERE deleted_at IS NULL`を書き忘れなく足す必要があり、1箇所でも忘れると、削除済みのはずの顧客が集計やUI一覧に出てくる。さらにUNIQUE制約は削除済み行も含めて評価されるため、`email UNIQUE`な顧客テーブルで論理削除した顧客と同じメールアドレスで再登録しようとすると、削除済みのはずの行と衝突してエラーになる。

緩和策は2つある。

部分インデックスで、生存行だけにUNIQUE制約を絞る。部分インデックスとは、`WHERE`で条件を付けて
「その条件に合う行だけ」を載せた索引である（索引としての性質は[第7回](07-storage-indexes-collation.md)で扱う）。
一意インデックスに条件を付ければ、一意性の検査対象も同じ範囲に絞れる。

```sql
ALTER TABLE customers DROP CONSTRAINT customers_email_key;
CREATE UNIQUE INDEX customers_email_active_key
  ON customers (email) WHERE deleted_at IS NULL;
```

これで削除済み行のメールアドレスは再利用可能になる。

```sql
UPDATE customers SET deleted_at = now() WHERE id = 1;
INSERT INTO customers (email) VALUES ('a@example.com');  -- id=1と同じメールでも成功する
```

ビューで「生存行だけ」を既定にする。ビューとは、名前を付けて保存した`SELECT`文であり、
参照する側からはテーブルと同じように扱える（実データは持たず、参照のたびに元の`SELECT`が実行される）。

```sql
CREATE VIEW active_customers AS
SELECT * FROM customers WHERE deleted_at IS NULL;
```

アプリのクエリの起点をこのビューにしておけば、`WHERE deleted_at IS NULL`の書き忘れをビュー定義側に一本化できる。ただしJOINが絡む複雑なクエリではビューだけでは防ぎきれない場合があり、根本的には「本当に論理削除が必要か」を都度疑うべきである。他テーブルから参照されず、単に一覧から消したいだけなら、物理DELETEしてアーカイブテーブルに移す設計の方が伝染コストを避けられる。

### 多対多は実体化する

`order_items`は「注文×商品」の多対多関係を表す中間テーブルであり、`qty`や`unit_price`という**関係そのものに属する属性**を持つ。

```sql
CREATE TABLE order_items (
  order_id    bigint        NOT NULL REFERENCES orders(id),
  product_id  bigint        NOT NULL REFERENCES products(id),
  qty         int           NOT NULL CHECK (qty > 0),
  unit_price  numeric(10,2) NOT NULL CHECK (unit_price >= 0),
  PRIMARY KEY (order_id, product_id)
);
```

`qty`や`unit_price`は「注文」の属性でも「商品」の属性でもない。「この注文でこの商品を何個いくらで買ったか」という**関係の属性**であり、これを持たせられる場所は中間テーブルしかない。これが多対多を「実体化する（中間テーブルとして具体的な行にする）」理由である。

関係に属性がない単純な多対多、たとえば商品とタグの「タグ付け」であっても、`products.tag_ids integer[]`のような配列列で済ませるのはアンチパターンである。外部キー制約が効かずタグ削除時に整合性が崩れる、インデックスでの絞り込みが素直にできない、「このタグを持つ商品を全部」のような集合演算がSQLとして書きにくい、という欠点がある。多対多は関係に属性があってもなくても中間テーブルで実体化するのが基本線であり、配列列は検索に使わない軽い表示用途などに限定して使う。

### 非正規化の判断基準

非正規化とは、正規化で分離した値を意図的に重複させて持つことである。典型例は集計列のキャッシュで、たとえば「顧客ごとの累計購入額」を都度`order_items`から`SUM`で計算する代わりに、`customers.total_spent`のような列にキャッシュしておく設計を指す。

非正規化は無料ではない。元データ（`order_items`）が変わるたびにキャッシュ列を更新しないと値がずれる。更新方法は主に2つある。

| 方式 | 整合性 | コスト | 向く場面 |
|---|---|---|---|
| トリガで都度更新 | 高い（即時） | 書き込みのたびにトリガが走り、書き込み遅延・ロック競合が増える | 参照頻度が非常に高く、多少の書き込み遅延を許容できる |
| バッチで定期更新 | 低い（遅延あり） | 実装が単純。ただし更新間隔の分だけ値が古くなる | 「だいたい合っていればよい」ダッシュボード的な集計 |

判断基準は次の3点で考える。(1) 読み取り頻度が書き込み頻度に対して圧倒的に高いか。(2) 元テーブルの集計コストが実際にボトルネックになっているか（[第8回](08-explain.md)でEXPLAINを使って確かめる）。(3) 多少のズレを業務上許容できるか。正規化のままJOINやSUMで十分な速度が出るなら非正規化は不要であり、非正規化は「測って初めて選ぶ」最適化である。

## ハンズオン

### 課題1: ある日時の価格を引くクエリと重複防止

`product_prices`にtstzrangeの生成列と`EXCLUDE`制約を追加し、次の2点を確認する。

1. 商品id=1の既存の有効期間と重なる`valid_from`/`valid_to`でINSERTするとエラーになること
2. 商品id=1の「ある日時時点の価格」を1つのSQLで引けること

**想定解答**

```sql
CREATE EXTENSION IF NOT EXISTS btree_gist;

ALTER TABLE product_prices
  ADD COLUMN valid_period tstzrange
  GENERATED ALWAYS AS (tstzrange(valid_from, valid_to, '[)')) STORED;

ALTER TABLE product_prices
  ADD CONSTRAINT product_prices_no_overlap
  EXCLUDE USING gist (product_id WITH =, valid_period WITH &&);

-- 1. 商品id=1の「現在も有効」な行(valid_to IS NULL)と重なる期間でINSERTしてみる
INSERT INTO product_prices (product_id, price, valid_from, valid_to)
SELECT product_id, 999, valid_from + interval '1 day', valid_to
FROM product_prices
WHERE product_id = 1 AND valid_to IS NULL;
-- => ERROR: conflicting key value violates exclusion constraint "product_prices_no_overlap"

-- 2. ある日時の価格
SELECT price
FROM product_prices
WHERE product_id = 1
  AND valid_period @> '2024-06-15 00:00:00+09'::timestamptz;
```

```text
 price
--------
 2480.00
(1 row)
```

`EXCLUDE`制約は`product_id`単位で区間の重なりを見るため、他の商品の期間とは無関係に判定される。`@>`演算子はGiSTインデックスがあればインデックススキャンで評価され、valid_from/valid_toの2条件と比べても実行計画上素直になりやすい（実行計画の読み方は[第8回](08-explain.md)で扱う）。

### 課題2: 注文ステータスの不正遷移を防ぐ

状態遷移図（pending→paid→shipped→delivered、pending→cancelled、paid→refunded）に従い、`orders`テーブルで不正な状態遷移をDB側で防ぐトリガを実装し、pendingからdeliveredへ直接遷移させようとするとエラーになることを確認する。

**想定解答**

本編の`check_order_status_transition`関数と`trg_order_status_transition`トリガをそのまま作成したうえで、次のように確認する。

```sql
-- テスト用にpending状態の注文を1件作る
INSERT INTO orders (customer_id, status, ordered_at)
VALUES (1, 'pending', now())
RETURNING id;
-- => idが返る。以下 :id をこの値に読み替える

-- 不正遷移を試す
UPDATE orders SET status = 'delivered' WHERE id = :id;
```

```text
ERROR:  invalid order status transition: pending -> delivered
```

```sql
-- 正しい経路なら通る
UPDATE orders SET status = 'paid' WHERE id = :id;
UPDATE orders SET status = 'shipped' WHERE id = :id;
SELECT id, status FROM orders WHERE id = :id;
```

```text
 id | status
----+---------
  1 | shipped
(1 row)
```

トリガはOLD.statusとNEW.statusの組を許可リストと照合するだけなので、遷移経路の追加・変更は許可リストに1行足すだけで済み、業務ルールの変更に追従しやすい。

## よくあるつまずきと対処

| 症状 | 原因 | 対処 |
|---|---|---|
| 全SELECTに`WHERE deleted_at IS NULL`を書き続けて疲弊する、または書き忘れて削除済みデータが出る | 論理削除の伝染コストを見積もらずに導入した | 生存行だけを返すビューを起点にする、または部分インデックスで代替する。他テーブルから参照されないなら物理削除+アーカイブを検討する |
| 「現在価格」と「価格履歴」の値がずれる | `products.price`と`product_prices`を別々に更新し、片方だけ更新し忘れる | 現在価格を`products.price`に二重管理せず、`product_prices`の`valid_to IS NULL`の行から都度取得する（ビュー化してもよい）。二重管理するならトリガで同期する |
| 同じ商品の価格改定が重複してINSERTされてしまう | valid_from/valid_toだけではDBが重複を拒否できない | `EXCLUDE USING gist`制約を張る（本編・課題1参照） |
| CHECK制約でstatusの遷移を守ろうとして書けない、または想定通り機能しない | CHECK制約は1行しか見えず、OLD値と比較できない | トリガ、またはUPDATE文のWHERE句に現在状態を条件として入れる方針に切り替える |
| 集計列（`total_spent`など）の値が実データとずれていく | 非正規化した集計列を更新するトリガ・バッチに漏れがある、または一部の更新経路（バッチ処理など）がトリガを経由していない | 更新経路を1つに絞る、またはトリガで全経路を確実に捕捉する。定期的に実データと突合するバッチ検証を入れる |

## 到達度チェックリスト

- [ ] valid_from/valid_to方式とtstzrange方式の長所・短所をそれぞれ説明できる
- [ ] btree_gist拡張を有効化し、product_pricesに`EXCLUDE USING gist`制約を追加できる
- [ ] 重複する有効期間のINSERTがエラーになることを確認できた
- [ ] 「ある日時の価格」を引くクエリを2通り（valid_from/valid_to条件、tstzrangeの`@>`）で書ける
- [ ] CHECK制約だけでは状態遷移を守れない理由を説明できる
- [ ] トリガまたはUPDATE文のWHERE句で、不正な状態遷移を防ぐ設計を実装できた
- [ ] 論理削除の伝染コストと、部分インデックス・ビューによる緩和策を説明できる
- [ ] 非正規化した集計列の整合性維持コストを、トリガ方式とバッチ方式で比較して語れる

## 宿題（次回までの自習）

1. `order_items`の合計金額を`orders.total_amount`としてキャッシュする設計を考え、AFTER INSERT/UPDATE/DELETEトリガで同期する実装を書く。トリガが走った場合と、走らない操作（`TRUNCATE order_items`など）が起きた場合とで、どうずれるかも確認する。
2. `categories`テーブルに`deleted_at`列を追加し、部分インデックスと`active_categories`ビューを作成する。削除済みカテゴリと同名のカテゴリを再作成できることを確認する。

## 次回への接続

本編で触れた`EXCLUDE`制約やUNIQUE制約の裏側は、B-treeやGiSTといったインデックス構造そのものに支えられている。[第7回](07-storage-indexes-collation.md)では、これらのインデックスがディスク上でどう格納され、なぜ範囲検索や重複防止に向いているのかを扱う。また本編で「実行計画は第8回で」と留保した`@>`演算子の評価方法は、[第8回](08-explain.md)でEXPLAINとともに扱う。

## 参考

- PostgreSQL公式ドキュメント: Range Types（https://www.postgresql.org/docs/current/rangetypes.html）
- PostgreSQL公式ドキュメント: btree_gist（https://www.postgresql.org/docs/current/btree-gist.html）
- PostgreSQL公式ドキュメント: CREATE TABLE の「排他制約」節（https://www.postgresql.org/docs/current/sql-createtable.html）
- PostgreSQL公式ドキュメント: Trigger Functions（https://www.postgresql.org/docs/current/plpgsql-trigger.html）
- PostgreSQL公式ドキュメント: Partial Indexes（https://www.postgresql.org/docs/current/indexes-partial.html）
