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

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

この回のねらい

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

到達目標

前提と準備

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

接続だけ確認しておく。

psql -d postshop -c "SELECT count(*) FROM customers;"
 count
-------
 50000
(1 row)

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

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

用語一行での説明
関係(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)属性が取りうる値の範囲

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

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

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

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

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

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

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

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

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

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

SELECT の論理的評価順序

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

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

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

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

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

 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)

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

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行すべてが対象である。

 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つも作られていない。

 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 ごとにまとめる。ここで扱う単位が「行の集合」から 「グループの集合」に変わる。以降の段階が見ているのは個々の行ではなくグループである。

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

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

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

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

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

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

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

 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 と使い分ける。

-- エラー: WHERE の時点では集約関数の結果はまだ存在しない
SELECT region, count(*) AS cnt
FROM customers
WHERE count(*) > 1000
GROUP BY region;
ERROR:  aggregate functions are not allowed in WHERE
-- 正しい: 集約後の条件は HAVING に書く
SELECT region, count(*) AS cnt
FROM customers
GROUP BY region
HAVING count(*) > 1000
ORDER BY cnt DESC;
   region   | cnt
------------+------
 関東       | 18234
 近畿       |  9871
 ...
(5 rows)

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

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

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

集合演算 ―― UNION / INTERSECT / EXCEPT

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

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

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

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

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

-- 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 の取りうる値そのものの集合を確認したいときは、集約とあわせるとよい。

-- customers に実在する地域の集合(重複なし)
SELECT DISTINCT region FROM customers ORDER BY region;
  region
----------
 中国
 九州
 四国
 中部
 北海道・東北
 東北
 近畿
 関東
(8 rows)

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

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回で扱う。

三値論理と NULL

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

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

AND

ANDTFU
TTFU
FFFF
UUFU

OR

ORTFU
TTTT
FTFU
UTUU

NOT

NOT
TF
FT
UU

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

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

SELECT * FROM (
  VALUES
    (1, '関東'),
    (2, '近畿'),
    (3, NULL::text)
) AS t(id, region);
 id | region
----+--------
  1 | 関東
  2 | 近畿
  3 |
(3 rows)

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

SELECT * FROM (
  VALUES (1, '関東'), (2, '近畿'), (3, NULL::text)
) AS t(id, region)
WHERE region = NULL;
 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 を返さない)特別な演算子である。

SELECT * FROM (
  VALUES (1, '関東'), (2, '近畿'), (3, NULL::text)
) AS t(id, region)
WHERE region IS NULL;
 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 のものを含む)テーブルで確認する。

SELECT
  count(*)      AS all_rows,
  count(region) AS non_null_regions
FROM (
  VALUES (1, '関東'), (2, '近畿'), (3, NULL::text)
) AS t(id, region);
 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 文に落とし、実行して結果を確認する。

想定解答

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

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(*) である。

SELECT region, count(*) AS customer_count
FROM customers
GROUP BY region
ORDER BY customer_count DESC;
   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行のテーブルを作って確かめる。

-- 手順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本の実行結果は順に次のようになる。

 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 WHEREWHERE は論理的に GROUP BY より前に評価され、集約結果はまだ存在しない集約後の条件は HAVING に書く
WHERE で SELECT の別名(AS)を参照してエラーになるSELECT は論理的に WHERE より後に評価されるWHERE には元の式・列名を書く。別名は GROUP BY/ORDER BY 以降で使う
COUNT(region) の結果が COUNT(*) より小さくて戸惑うCOUNT(列名) はその列が NULL でない行しか数えない「行数」がほしいのか「値がある行数」がほしいのかを先に決めてから使い分ける
SQL の実行順序を「書いた順(SELECT→FROM→WHERE...)」だと思い込みハマる書く順序と論理的評価順序が異なることを知らない本編の評価順序の表を都度参照する。「エラーが出た行」ではなく「エラーが出た評価段階」で考える

到達度チェックリスト

宿題(次回までの自習)

  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回では、 この土台の上に JOIN・サブクエリ・CTE・ウィンドウ関数を積み、アプリ側でループしていた処理を実際に 1本の SQL に置き換えていく。今回の評価順序の理解は、JOIN がどこで効いてくるか(FROM 句の中で 複数テーブルの直積を作ってから WHERE/ON で絞り込む)を理解する前提になる。

参考