---
note: テーブル＝集合。宣言的SQLへの頭の切り替えと、NULL・三値論理・SELECTの論理的評価順序
created: 2026-08-14T10:00:00+09:00
---

# 第2回｜関係モデルとは何だったのか

> テーブルは「集合」であり、SQL は「何が欲しいか」を宣言する言語である。手続き型の頭からの切り替えが今回の本題。

## この回のねらい

前回で環境とデータセットが揃った。今回はまだ SQL の文法を網羅的に学ぶ回ではない。それより先に、
「テーブルとは何か」「SQL を書くときに頭の中で何が起きているべきか」を切り替える回である。
プログラミング経験がある読者ほど、無意識に「先頭から1件ずつループして処理する」発想で SQL を書こうと
してしまう。関係モデルの前提を押さえ、集合演算として問題を言い換える練習をすることで、この癖を早い
段階で矯正する。あわせて SELECT の論理的な評価順序と、NULL がもたらす三値論理という、以降の全回で
前提知識として使う2つの基礎を固める。

## 到達目標

- 「ループで1件ずつ処理して集計する」手続き的な処理を、集合演算・関係演算の言葉で言い換えられる。
- `SELECT` 文の論理的評価順序（`FROM` → `WHERE` → `GROUP BY` → `HAVING` → `SELECT` → `ORDER BY`）を、
  なぜその順序でなければならないかを添えて説明できる。
- `NULL` が「値がない」ではなく「不明（unknown）」を表すことを理解し、比較演算が二値論理ではなく
  三値論理になることを説明できる。
- `WHERE col = NULL` が常に0行になる理由と、`IS NULL` / `IS NOT NULL` で書き直す方法を説明できる。
- `COUNT(*)` と `COUNT(列名)` の違いを、`NULL` の扱いの観点から説明できる。

## 前提と準備

第1回で構築した PostgreSQL 16 系のデータベースに、共通スキーマ（`customers` / `categories` /
`products` / `product_prices` / `orders` / `order_items` / `events`）とデータが投入済みであることを
前提にする。今回使うのは主に `customers` だが、集合演算の実験では説明用に小さな `VALUES` テーブルも
その場で作る。特別な拡張機能は不要である。

接続だけ確認しておく。

```bash
psql -d postshop -c "SELECT count(*) FROM customers;"
```

```text
 count
-------
 50000
(1 row)
```

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

前半3つはこの回の主題そのもので、本編で詳しく扱う。後半は本編・ハンズオンで補助的に使う記法である。回をまたいで使う語は[用語集](glossary.md)にもまとめている。

| 用語 | 一行での説明 |
|---|---|
| 関係（relation） | 属性の組（タプル）を要素とする集合。RDB のテーブルにあたる |
| 述語（predicate） | 行を1つ受け取って真・偽・不明のいずれかを返す条件式 |
| 三値論理 | 真・偽に「不明（unknown）」を加えた3値で比較を扱う論理 |
| 集合演算 | 2つの結果集合に対する和・積・差の演算（`UNION`/`INTERSECT`/`EXCEPT`） |
| `VALUES` | 行の並びをその場で書き下し、テーブルとして扱う構文 |
| `::`（キャスト） | 値を別の型として解釈し直す記法。`NULL::text` は「text型のNULL」 |
| `DISTINCT` | 結果から重複行を取り除く指定 |

## 本編

### 関係モデルとは何か

関係モデル（relational model）は 1970 年に E.F. Codd が提案したデータモデルで、「データを数学的な
**関係（relation）**、すなわち集合として扱う」という考え方が核にある。PostgreSQL を含む
RDBMS（Relational Database Management System、関係データベース管理システム）の「テーブル」は、
この関係を具体的に実装したものである。

用語の対応を先に整理する。

| 関係モデルの用語 | RDB での対応 | 意味 |
|---|---|---|
| 関係（relation） | テーブル | ある型の属性を持つタプルの集合 |
| タプル（tuple） | 行（row） | 1件のデータ。属性値の組 |
| 属性（attribute） | 列（column） | タプルを構成する名前付きの値 |
| 定義域（domain） | 型（type） | 属性が取りうる値の範囲 |

ここで重要なのは「集合」という言葉が指す性質である。数学的な集合には、次の特徴がある。

- **重複がない**（同じ要素を2つ持たない）。
- **順序がない**（要素の並び順は集合の一部ではない）。

関係モデル上の理想では、テーブルの行にも本来この性質が期待される。実際の RDB のテーブルは主キーが
なければ重複行を許すし、`ORDER BY` を書かない限り行の返却順序は保証されない――むしろ「順序は保証
されない」という前提こそが集合的な発想の出発点になる。「先頭から」「次に」という考え方自体が、
集合には馴染まない。`SELECT * FROM orders` を実行して返ってくる行の並びは、実装の都合（テーブル
スキャンの物理順序、インデックスの利用有無など）にすぎず、意味論上は何ら保証されていない。これは
初学者が最初につまずく感覚のずれである。「順番に並んでいるはず」という思い込みを捨てるところから
始める。

### 述語論理と WHERE ―― 「どの行を選ぶか」を条件式として書く

手続き型の言語で「ある条件を満たす行を集める」処理を書くなら、次のような擬似コードになるはずである。

```text
result = []
for row in customers:
    if row.region == '関東':
        result.append(row)
return result
```

SQL ではこれを次のように書く。

```sql
SELECT *
FROM customers
WHERE region = '関東';
```

一見すると単なる文法の違いに見えるが、発想はまったく異なる。擬似コードは「どう集めるか」という
**手順**を記述している（1件ずつ見て、条件を満たすものを箱に入れる）。一方 SQL の `WHERE` 句は、
「集合 `customers` のうち、述語 `region = '関東'` を満たす要素だけからなる部分集合」を**宣言**して
いるにすぎない。どういう手順でその部分集合を求めるか（全件スキャンか、インデックスを使うか）は
PostgreSQL の**プランナ**が決める話であり、SQL を書く側の関心事ではない。プランナとは、受け取った
SQL に対して「どう取ってくるか」の候補を複数立て、統計情報をもとにコストを見積もって一番安いものを
選ぶ内部コンポーネントである。つまり SQL は「何が欲しいか」だけを書き、「どう取るか」はプランナに
委ねる——この分業が「宣言的（declarative）」と「手続き的（procedural）」の違いそのものである。
プランナの出した答えを実際に覗く方法は[第8回](08-explain.md)で扱う。

`WHERE` 句に書く条件式は、論理学でいう**述語（predicate）**である。述語とは、行を1つ受け取って
真（true）・偽（false）（と、後述する不明 unknown）のいずれかを返す関数だと考えるとよい。
`WHERE` はテーブルの各行にこの述語を適用し、真になった行だけを残す。複数条件は `AND` / `OR` /
`NOT` で組み合わせる、これも命題論理そのものである。

```sql
SELECT *
FROM customers
WHERE region = '関東' AND created_at >= '2024-01-01';
```

### SELECT の論理的評価順序

SQL の `SELECT` 文は書く順序（`SELECT` → `FROM` → `WHERE` → `GROUP BY` → `HAVING` → `ORDER BY`）と、
実際に評価される論理的な順序が異なる。この不一致こそが初学者の混乱の主因なので、はっきり切り分けて
覚える。

| 書く順序 | 論理的な評価順序 | やっていること |
|---|---|---|
| 3. `FROM` | **1. FROM**（+ JOIN） | どのテーブル（の直積）を対象にするか決める |
| 4. `WHERE` | **2. WHERE** | 行単位でフィルタする（集約前） |
| 5. `GROUP BY` | **3. GROUP BY** | 行をグループにまとめる |
| 6. `HAVING` | **4. HAVING** | グループ単位でフィルタする（集約後） |
| 1. `SELECT` | **5. SELECT** | 出力する列・式を計算する（別名はここで初めて定まる） |
| 7. `ORDER BY` | **6. ORDER BY** | 最後に並べ替える |

```text
FROM / JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY
```

### 1本のクエリを段階ごとに追う

順序を表で覚えるだけでは、データが実際にどう変形していくかは掴めない。`WHERE`・`GROUP BY`・
`HAVING`・`ORDER BY` をすべて含む1本のクエリを、6段階に分けて追う。

対象データは次の9行とする（説明用に小さくした `customers` だと考えればよい）。

```text
 id | region | created_at
----+--------+------------
  1 | 関東   | 2024-03-05
  2 | 関東   | 2024-05-12
  3 | 近畿   | 2023-11-20
  4 | 近畿   | 2024-02-14
  5 | 中部   | 2024-01-08
  6 | 中部   | 2024-07-30
  7 | 九州   | 2023-08-03
  8 | 九州   | 2023-12-25
  9 | 関東   | 2024-09-01
(9 rows)
```

追いかけるクエリはこれである。

```sql
SELECT region, count(*) AS cnt
FROM customers
WHERE created_at >= '2024-01-01'
GROUP BY region
HAVING count(*) >= 2
ORDER BY cnt DESC;
```

**1. FROM** ―― 対象のテーブルを決める。この段階では9行すべてが対象である。

```text
 id | region | created_at
----+--------+------------
  1 | 関東   | 2024-03-05
  2 | 関東   | 2024-05-12
  3 | 近畿   | 2023-11-20
  4 | 近畿   | 2024-02-14
  5 | 中部   | 2024-01-08
  6 | 中部   | 2024-07-30
  7 | 九州   | 2023-08-03
  8 | 九州   | 2023-12-25
  9 | 関東   | 2024-09-01
(9 rows)
```

**2. WHERE** ―― 行単位でフィルタする。`created_at >= '2024-01-01'` を満たさない `id=3, 7, 8` が
落ちて6行になる。まだグループは1つも作られていない。

```text
 id | region | created_at
----+--------+------------
  1 | 関東   | 2024-03-05
  2 | 関東   | 2024-05-12
  4 | 近畿   | 2024-02-14
  5 | 中部   | 2024-01-08
  6 | 中部   | 2024-07-30
  9 | 関東   | 2024-09-01
(6 rows)
```

**3. GROUP BY** ―― 残った6行を `region` ごとにまとめる。ここで扱う単位が「行の集合」から
「グループの集合」に変わる。以降の段階が見ているのは個々の行ではなくグループである。

```text
関東 → {1, 2, 9} (3件)
近畿 → {4}       (1件)
中部 → {5, 6}    (2件)
(3 groups)
```

`id=7, 8` の九州は `WHERE` で全行が落ちたため、そもそもグループとして存在しない。

**4. HAVING** ―― グループ単位でフィルタする。`count(*) >= 2` を満たさない近畿（1件）が落ちる。
`WHERE` が行を落としたのに対し、`HAVING` はグループを落としている。

```text
関東 → {1, 2, 9} (3件)
中部 → {5, 6}    (2件)
(2 groups)
```

**5. SELECT** ―― 出力する列・式を計算する。`count(*)` の値がここで確定し、別名 `cnt` もここで
初めて存在するようになる。裏を返せば、これより前の `WHERE` や `HAVING` の時点では `cnt` という
名前はまだどこにも存在しない。

```text
 region | cnt
--------+-----
 関東   |   3
 中部   |   2
(2 rows)
```

**6. ORDER BY** ―― 最後に並べ替える。`SELECT` の後なので、ここでは別名 `cnt` を参照できる。

```text
 region | cnt
--------+-----
 関東   |   3
 中部   |   2
(2 rows)
```

この例で最も注目すべきは近畿の動きである。近畿は元データでは `id=3, 4` の2行を持っており、
`HAVING count(*) >= 2` の条件だけを見れば通過するように見える。しかし `id=3` は `created_at` が
2023-11-20 なので、段階2の `WHERE` で先に落ちる。段階3でグループが作られる時点で近畿はすでに1件
しか残っておらず、段階4の `HAVING` を通過できない。

仮に `HAVING` が `WHERE` より先に評価されるなら、近畿は2件のグループとして条件を満たし、結果に
残っていたはずである。`WHERE` が `GROUP BY` より先に評価されるという順序が、そのまま結果を変えて
いる。`WHERE` と `HAVING` の使い分けは書き手の好みではなく、「集約前の行を絞るのか、集約後の
グループを絞るのか」という意味の違いであり、どちらに条件を書くかで得られる答えが変わる。

この表と流れから読み取れるとおり、`SELECT` で指定した列の別名（`AS`）は `WHERE` や `HAVING` の
時点ではまだ存在しない。これが次の2点をきれいに説明する。

**なぜ `WHERE` で集約関数（`COUNT` や `SUM` など）が使えないのか。** `WHERE` は論理的に `GROUP BY`
より前に評価される（上の例の段階2と段階3の関係）。集約関数はグループがまとまった後で初めて計算
できる値なので、まだグループ化されていない段階の `WHERE` からは参照できない。「グループ化される
前の生の行」を対象にした条件は `WHERE`、「グループ化された後の集計結果」を対象にした条件は
`HAVING` と使い分ける。

```sql
-- エラー: WHERE の時点では集約関数の結果はまだ存在しない
SELECT region, count(*) AS cnt
FROM customers
WHERE count(*) > 1000
GROUP BY region;
```

```text
ERROR:  aggregate functions are not allowed in WHERE
```

```sql
-- 正しい: 集約後の条件は HAVING に書く
SELECT region, count(*) AS cnt
FROM customers
GROUP BY region
HAVING count(*) > 1000
ORDER BY cnt DESC;
```

```text
   region   | cnt
------------+------
 関東       | 18234
 近畿       |  9871
 ...
(5 rows)
```

**なぜ `WHERE` で `SELECT` 句の別名を使えないのか。** `SELECT` は論理的に `WHERE` より後に評価
される（上の例の段階2と段階5の関係）ため、`SELECT` で定義した別名は `WHERE` の時点でまだ存在
しない（`GROUP BY` や `HAVING`、`ORDER BY` では使えることが多いが、これは PostgreSQL の拡張的な
便宜であり、標準的な評価順序の理解としては「後段のもの」と覚えておくのが安全である）。

```sql
-- エラー: WHERE の時点では customer_count という名前はまだ存在しない
SELECT region, count(*) AS customer_count
FROM customers
WHERE customer_count > 1000
GROUP BY region;
```

```text
ERROR:  column "customer_count" does not exist
```

書く順序（`SELECT` を先頭に書く）と評価順序（`SELECT` は後ろの方で評価される）を混同すると、この
種のエラーの原因が見えなくなる。エラーに出会ったら「これは書き順の何行目か」ではなく「これは論理的
評価順序のどの段階の話か」で考え直す。

### 集合演算 ―― UNION / INTERSECT / EXCEPT

テーブルが集合であるという性質は、集合演算をそのまま SQL の演算子として使えることに直結する。
`customers.region` の値の集合を例に、3つの演算子を確認する。

まず「関東」に住む顧客のメールアドレスの集合と、「作成日が2024年より前」の顧客のメールアドレスの
集合を、それぞれ別のクエリとして用意する。

```sql
-- 関東の顧客
SELECT email FROM customers WHERE region = '関東';

-- 2024年より前に登録した顧客
SELECT email FROM customers WHERE created_at < '2024-01-01';
```

この2つの結果集合に対して、集合演算子を適用できる。

```sql
-- UNION: どちらか一方にでも含まれる顧客（重複は自動的に除去される）
SELECT email FROM customers WHERE region = '関東'
UNION
SELECT email FROM customers WHERE created_at < '2024-01-01';

-- INTERSECT: 両方に含まれる顧客（関東 かつ 2024年より前）
SELECT email FROM customers WHERE region = '関東'
INTERSECT
SELECT email FROM customers WHERE created_at < '2024-01-01';

-- EXCEPT: 前者にはあるが後者にはない顧客（関東 だが 2024年以降の登録）
SELECT email FROM customers WHERE region = '関東'
EXCEPT
SELECT email FROM customers WHERE created_at < '2024-01-01';
```

| 演算子 | 数学的な対応 | 意味 |
|---|---|---|
| `UNION` | 和集合 $A \cup B$ | どちらかに含まれる行（重複除去） |
| `UNION ALL` | 多重集合の和 | どちらかに含まれる行（重複を除去しない。速い） |
| `INTERSECT` | 積集合 $A \cap B$ | 両方に含まれる行 |
| `EXCEPT` | 差集合 $A \setminus B$ | 前者にあり後者にない行 |

`region` の**取りうる値そのものの集合**を確認したいときは、集約とあわせるとよい。

```sql
-- customers に実在する地域の集合(重複なし)
SELECT DISTINCT region FROM customers ORDER BY region;
```

```text
  region
----------
 中国
 九州
 四国
 中部
 北海道・東北
 東北
 近畿
 関東
(8 rows)
```

`UNION` は列数・列の型が対応していれば、異なるテーブル由来の結果同士でも使える。例えば「注文をした
ことがある顧客のメール」と「イベントを発生させたことがある顧客のメール」の和集合、といった使い方も
同じ理屈である。

```sql
SELECT DISTINCT c.email
FROM customers c JOIN orders o ON o.customer_id = c.id
UNION
SELECT DISTINCT c.email
FROM customers c JOIN events e ON e.customer_id = c.id;
```

`UNION` と `UNION ALL` の使い分けは実務でよく問われる。重複を除去する処理（内部的にソートまたは
ハッシュを使う）にはコストがかかるため、「重複が発生しないと分かっている」「重複があっても構わない」
場面では `UNION ALL` を使う方が速い。安易に `UNION` を使う癖がついていると、無駄な重複除去コストを
払い続けることになる。実行計画の詳しい読み方は[第8回](08-explain.md)で扱う。

### 三値論理と NULL

関係モデルの用語で `NULL` は「値がない」ではなく、「その属性の値が**不明（unknown）**である」ことを
表す。この違いは重要である。「値がない」なら「等しくない」と断定できそうだが、「不明」な値同士を
比較した結果は、やはり「不明」にしかなりようがない。この結果、SQL の比較演算は真（true）・偽
（false）の二値ではなく、そこに不明（unknown）を加えた**三値論理**で動く。

`AND` / `OR` / `NOT` の真理値表は次のようになる（`T` = true、`F` = false、`U` = unknown）。

**AND**

| AND | T | F | U |
|---|---|---|---|
| **T** | T | F | U |
| **F** | F | F | F |
| **U** | U | F | U |

**OR**

| OR | T | F | U |
|---|---|---|---|
| **T** | T | T | T |
| **F** | T | F | U |
| **U** | T | U | U |

**NOT**

| NOT |  |
|---|---|
| T | F |
| F | T |
| U | U |

`WHERE` 句は、述語の評価結果が **T（真）のときだけ** その行を残す。F はもちろん、U（不明）の行も
残さない。ここが「不明だから念のため含める」ではないという、初学者が誤解しやすい点である。

実験してみる。地域が `NULL` の顧客を含む小さなテーブルを `VALUES` で作る。

```sql
SELECT * FROM (
  VALUES
    (1, '関東'),
    (2, '近畿'),
    (3, NULL::text)
) AS t(id, region);
```

```text
 id | region
----+--------
  1 | 関東
  2 | 近畿
  3 |
(3 rows)
```

ここで「地域が不明な顧客」を `= NULL` で探そうとすると、何も返ってこない。

```sql
SELECT * FROM (
  VALUES (1, '関東'), (2, '近畿'), (3, NULL::text)
) AS t(id, region)
WHERE region = NULL;
```

```text
 id | region
----+--------
(0 rows)
```

理由は真理値表のとおりである。`region = NULL` という式は、`region` の値が何であっても「不明の値と
比較して等しいかどうかは不明」、すなわち常に **U（unknown）** と評価される。`region` が `'関東'`
であっても `NULL` であっても、`region = NULL` は unknown にしかならない。`WHERE` は T の行しか
残さないので、`= NULL` を使ったフィルタは常に0行になる。これはバグではなく、三値論理の定義どおりの
挙動である。

正しくは `IS NULL` / `IS NOT NULL` という専用の述語を使う。これらは三値論理ではなく、常に T か F の
どちらかを返す（unknown を返さない）特別な演算子である。

```sql
SELECT * FROM (
  VALUES (1, '関東'), (2, '近畿'), (3, NULL::text)
) AS t(id, region)
WHERE region IS NULL;
```

```text
 id | region
----+--------
  3 |
(1 row)
```

`<>`（不等号）でも同じ罠にはまる。`region <> NULL` も常に unknown であり、`NULL` の行を「除外も
含めも」できない。「`NULL` 以外」を確実に取りたいなら `region IS NOT NULL` を使う。

### COUNT(*) と COUNT(列名) の違い

集約関数 `COUNT` は2通りの書き方があり、`NULL` の扱いが異なる。`COUNT(*)` は行そのものを数える
ため `NULL` を含む行も1件として数えるが、`COUNT(列名)` はその列の値が `NULL` でない行だけを数える。

先ほどの3行（`region` が `NULL` のものを含む）テーブルで確認する。

```sql
SELECT
  count(*)      AS all_rows,
  count(region) AS non_null_regions
FROM (
  VALUES (1, '関東'), (2, '近畿'), (3, NULL::text)
) AS t(id, region);
```

```text
 all_rows | non_null_regions
----------+------------------
        3 |                2
(1 row)
```

`customers.region` は共通スキーマ上 `NOT NULL` 制約が付いているため、実データではこの2つは一致
する。だが実務のテーブルには `NULL` を許す列がいくらでもあり、「件数を数えたつもりが、実は `NULL`
の行を暗黙に除外していた」という取り違えは典型的なバグの温床になる。`COUNT(*)` で行数、
`COUNT(列名)` でその列に値が入っている行数、と役割を分けて意識する。

## ハンズオン

### 課題1: 「地域ごとの顧客数」を手続き的な言葉で書いてから1本のSELECTに落とす

まずコードを書く前に、この処理を「ループで1件ずつ処理するならどうなるか」を言葉（または擬似コード）
で書き出す。

**やること**

1. 「地域ごとの顧客数を数える」処理を、手続き的な擬似コードとして書く（プログラミング言語は問わない。
   for ループと辞書/マップを使うイメージでよい）。
2. それを1本の `SELECT` 文に落とし、実行して結果を確認する。

**想定解答**

まず手続き的な発想を言葉にすると、次のようになる。

```text
counts = {}                          # 地域 -> 件数 の辞書
for row in customers:                # customers を1件ずつ舐める
    key = row.region
    if key not in counts:
        counts[key] = 0
    counts[key] += 1                 # 該当する地域のカウンタを+1
return counts                        # 辞書をそのまま返す
```

「1件ずつ見て、対応するバケツに振り分けて、バケツごとに数える」という処理そのものが、SQL の
`GROUP BY` と集約関数がやっていることに一致する。「バケツに振り分ける」が `GROUP BY region`、
「バケツごとに数える」が `count(*)` である。

```sql
SELECT region, count(*) AS customer_count
FROM customers
GROUP BY region
ORDER BY customer_count DESC;
```

```text
   region     | customer_count
--------------+----------------
 関東         |          18234
 近畿         |           9871
 中部         |           6420
 九州         |           4988
 東北         |           3811
 中国         |           2705
 四国         |           2190
 北海道・東北 |           1781
(8 rows)
```

擬似コードとの対応をあらためて確認すると、`for row in customers` が論理評価順序の `FROM`、
`counts[key] += 1` の「振り分け」が `GROUP BY`、辞書をそのまま返す部分が集約後の `SELECT` に対応
する。ループを1回も書いていないのに、同じ結果に到達している。これが「宣言的に書く」ということの
具体的な意味である。

### 課題2: `= NULL` が効かないことを実験で確認する

**やること**

1. `region` が `NULL` の行を含む小さなテーブルを `VALUES` で作る（本編の例をそのまま使ってよい）。
2. `WHERE region = NULL` で検索し、0行になることを確認する。
3. `WHERE region IS NULL` に書き換えて、正しく検索できることを確認する。
4. `count(*)` と `count(region)` の差が、`NULL` の行の数と一致することを確認する。

**想定解答**

本編と同じ `FROM (VALUES ...) AS t(...)` の形で、`NULL` を2行含む4行のテーブルを作って確かめる。

```sql
-- 手順2: = NULL では見つからない
SELECT count(*) AS wrong_eq_null
FROM (VALUES (1, '関東'), (2, '近畿'), (3, NULL::text), (4, NULL::text)) AS t(id, region)
WHERE region = NULL;

-- 手順3: IS NULL なら正しく見つかる
SELECT count(*) AS correct_is_null
FROM (VALUES (1, '関東'), (2, '近畿'), (3, NULL::text), (4, NULL::text)) AS t(id, region)
WHERE region IS NULL;

-- 手順4: count(*) と count(列名) を並べる
SELECT count(*) AS all_rows, count(region) AS non_null_rows
FROM (VALUES (1, '関東'), (2, '近畿'), (3, NULL::text), (4, NULL::text)) AS t(id, region);
```

3本の実行結果は順に次のようになる。

```text
 wrong_eq_null
---------------
             0
(1 row)

 correct_is_null
-----------------
               2
(1 row)

 all_rows | non_null_rows
----------+---------------
        4 |             2
(1 row)
```

`wrong_eq_null` が0であることは、`region = NULL` が常に unknown と評価され `WHERE` を通過しないこと
の直接の証拠である。`correct_is_null` は正しく2件（`id=3` と `id=4`）を検出している。そして
`all_rows - non_null_rows`（`4 - 2 = 2`）が `NULL` の行数と一致する。`COUNT(*)` と `COUNT(列名)` の
差分を取ると「その列が `NULL` の行数」を求められる、という実務でよく使うテクニックでもある。

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

| 症状 | 原因 | 対処 |
|---|---|---|
| `WHERE region = NULL` で必ず0行になる | `= NULL` は三値論理で常に unknown になり、`WHERE` は T の行しか残さない | `WHERE region IS NULL` を使う |
| `WHERE region <> NULL` で「NULL以外」を取ろうとして0行になる | `<>` も同様に unknown になる | `WHERE region IS NOT NULL` を使う |
| `WHERE count(*) > 100` で `aggregate functions are not allowed in WHERE` | `WHERE` は論理的に `GROUP BY` より前に評価され、集約結果はまだ存在しない | 集約後の条件は `HAVING` に書く |
| `WHERE` で `SELECT` の別名（`AS`）を参照してエラーになる | `SELECT` は論理的に `WHERE` より後に評価される | `WHERE` には元の式・列名を書く。別名は `GROUP BY`/`ORDER BY` 以降で使う |
| `COUNT(region)` の結果が `COUNT(*)` より小さくて戸惑う | `COUNT(列名)` はその列が `NULL` でない行しか数えない | 「行数」がほしいのか「値がある行数」がほしいのかを先に決めてから使い分ける |
| SQL の実行順序を「書いた順（SELECT→FROM→WHERE...）」だと思い込みハマる | 書く順序と論理的評価順序が異なることを知らない | 本編の評価順序の表を都度参照する。「エラーが出た行」ではなく「エラーが出た評価段階」で考える |

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

- [ ] 「テーブル＝集合」「行＝タプル」「列＝属性」という関係モデルの用語対応を説明できる。
- [ ] 手続き的な「1件ずつループして集計する」処理を、`GROUP BY` + 集約関数の言葉で言い換えられる。
- [ ] `SELECT` の論理的評価順序（`FROM`→`WHERE`→`GROUP BY`→`HAVING`→`SELECT`→`ORDER BY`）を
      諳んじて言え、なぜ `WHERE` で集約関数が使えないかを説明できる。
- [ ] `UNION` / `INTERSECT` / `EXCEPT` を、和集合・積集合・差集合として説明し、実際に書ける。
- [ ] `UNION` と `UNION ALL` の違いと使い分けの判断基準を説明できる。
- [ ] 三値論理の `AND`/`OR`/`NOT` 真理値表を再現でき、`NULL` が「不明」を表すことを説明できる。
- [ ] `WHERE col = NULL` が常に0行になる理由を説明でき、`IS NULL` に書き換えられる。
- [ ] `COUNT(*)` と `COUNT(列名)` の違いを、`NULL` の扱いの観点から説明できる。

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

1. `products` テーブルで、`category_id` ごとの商品数を「手続き的な擬似コード」→「1本の `SELECT`」の
   順で書く（課題1と同じ型の練習を、別のテーブルでもう一度やる）。
2. `orders.status` の取りうる値の集合を `SELECT DISTINCT` で確認したうえで、「`status` が
   `'cancelled'` の注文をした顧客」と「`status` が `'refunded'` の注文をした顧客」のメールアドレスの
   集合について、`INTERSECT`（両方経験した顧客）と `EXCEPT`（`cancelled` はあるが `refunded` はない
   顧客）をそれぞれ1本ずつ書いて実行する。
3. 次回の JOIN に備えて、`orders` と `order_items` を素朴に `INNER JOIN` した場合の行数が、
   `orders` 単体の行数より多くなることを `count(*)` で確認しておく（なぜ増えるのかは次回扱う）。

## 次回への接続

今回で「集合として考える」「宣言的に書く」という土台ができた。[第3回](03-writing-sql.md)では、
この土台の上に JOIN・サブクエリ・CTE・ウィンドウ関数を積み、アプリ側でループしていた処理を実際に
1本の SQL に置き換えていく。今回の評価順序の理解は、JOIN がどこで効いてくるか（`FROM` 句の中で
複数テーブルの直積を作ってから `WHERE`/`ON` で絞り込む）を理解する前提になる。

## 参考

- PostgreSQL 公式ドキュメント: "The SQL Language" > "Queries"（`SELECT` の構文と評価の考え方）
- PostgreSQL 公式ドキュメント: "Functions and Operators" > "Comparison Functions and Operators"
  （三値論理・`IS NULL` の扱い）
- PostgreSQL 公式ドキュメント: "Functions and Operators" > "Aggregate Functions"（`COUNT` の挙動）
- PostgreSQL 公式ドキュメント: "The SQL Language" > "Queries" > "Combining Queries"
  （`UNION`/`INTERSECT`/`EXCEPT`）
- 関係モデルの入門章（E.F. Codd の関係モデルの基本的な考え方を扱う教科書・入門記事全般）
