テーブルは「集合」であり、SQL は「何が欲しいか」を宣言する言語である。手続き型の頭からの切り替えが今回の本題。
前回で環境とデータセットが揃った。今回はまだ SQL の文法を網羅的に学ぶ回ではない。それより先に、 「テーブルとは何か」「SQL を書くときに頭の中で何が起きているべきか」を切り替える回である。 プログラミング経験がある読者ほど、無意識に「先頭から1件ずつループして処理する」発想で SQL を書こうと してしまう。関係モデルの前提を押さえ、集合演算として問題を言い換える練習をすることで、この癖を早い 段階で矯正する。あわせて SELECT の論理的な評価順序と、NULL がもたらす三値論理という、以降の全回で 前提知識として使う2つの基礎を固める。
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 テーブルも
その場で作る。特別な拡張機能は不要である。
接続だけ確認しておく。
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 を実行して返ってくる行の並びは、実装の都合(テーブル
スキャンの物理順序、インデックスの利用有無など)にすぎず、意味論上は何ら保証されていない。これは
初学者が最初につまずく感覚のずれである。「順番に並んでいるはず」という思い込みを捨てるところから
始める。
手続き型の言語で「ある条件を満たす行を集める」処理を書くなら、次のような擬似コードになるはずである。
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';
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 | 最後に並べ替える |
FROM / JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY
順序を表で覚えるだけでは、データが実際にどう変形していくかは掴めない。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 は後ろの方で評価される)を混同すると、この
種のエラーの原因が見えなくなる。エラーに出会ったら「これは書き順の何行目か」ではなく「これは論理的
評価順序のどの段階の話か」で考え直す。
テーブルが集合であるという性質は、集合演算をそのまま 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 は「値がない」ではなく、「その属性の値が不明(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 で作る。
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 は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件ずつ処理するならどうなるか」を言葉(または擬似コード) で書き出す。
やること
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回も書いていないのに、同じ結果に到達している。これが「宣言的に書く」ということの
具体的な意味である。
= NULL が効かないことを実験で確認するやること
region が NULL の行を含む小さなテーブルを VALUES で作る(本編の例をそのまま使ってよい)。WHERE region = NULL で検索し、0行になることを確認する。WHERE region IS NULL に書き換えて、正しく検索できることを確認する。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 WHERE | WHERE は論理的に GROUP BY より前に評価され、集約結果はまだ存在しない | 集約後の条件は HAVING に書く |
WHERE で SELECT の別名(AS)を参照してエラーになる | SELECT は論理的に WHERE より後に評価される | WHERE には元の式・列名を書く。別名は GROUP BY/ORDER BY 以降で使う |
COUNT(region) の結果が COUNT(*) より小さくて戸惑う | COUNT(列名) はその列が NULL でない行しか数えない | 「行数」がほしいのか「値がある行数」がほしいのかを先に決めてから使い分ける |
| SQL の実行順序を「書いた順(SELECT→FROM→WHERE...)」だと思い込みハマる | 書く順序と論理的評価順序が異なることを知らない | 本編の評価順序の表を都度参照する。「エラーが出た行」ではなく「エラーが出た評価段階」で考える |
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 の扱いの観点から説明できる。products テーブルで、category_id ごとの商品数を「手続き的な擬似コード」→「1本の SELECT」の
順で書く(課題1と同じ型の練習を、別のテーブルでもう一度やる)。orders.status の取りうる値の集合を SELECT DISTINCT で確認したうえで、「status が
'cancelled' の注文をした顧客」と「status が 'refunded' の注文をした顧客」のメールアドレスの
集合について、INTERSECT(両方経験した顧客)と EXCEPT(cancelled はあるが refunded はない
顧客)をそれぞれ1本ずつ書いて実行する。orders と order_items を素朴に INNER JOIN した場合の行数が、
orders 単体の行数より多くなることを count(*) で確認しておく(なぜ増えるのかは次回扱う)。今回で「集合として考える」「宣言的に書く」という土台ができた。第3回では、
この土台の上に JOIN・サブクエリ・CTE・ウィンドウ関数を積み、アプリ側でループしていた処理を実際に
1本の SQL に置き換えていく。今回の評価順序の理解は、JOIN がどこで効いてくるか(FROM 句の中で
複数テーブルの直積を作ってから WHERE/ON で絞り込む)を理解する前提になる。
SELECT の構文と評価の考え方)IS NULL の扱い)COUNT の挙動)UNION/INTERSECT/EXCEPT)