🎯 この章で学ぶこと

  • 集計関数 COUNT・SUM・AVG・MAX・MIN と、COUNT(*)とCOUNT(列)の違い
  • GROUP BYによるグループ集計と、「SELECTに書ける列」の鉄則
  • HAVINGで集計結果をさらに絞る — WHEREとの実行順序の違い
  • INNER JOINでテーブルを結合する仕組みを、段階を追って理解する
  • LEFT JOINとの使い分け — 「対応する行がない側」をどう扱うか
  • サブクエリ(問い合わせの入れ子)の第一歩

この章も第3章で作った company.db を使います。sqlite3 company.db で開き、.mode column.headers on を実行してから進んでください。

4.1 集計関数 — 行の集まりを1つの値にする

これまでのSELECTは「行を選んで表示する」ものでした。集計関数は発想が違い、複数の行をまとめて1つの値を計算します。代表は次の5つです。

関数意味
COUNT(*)行数を数える社員は何人いるか
COUNT(列)その列がNULLでない行数を数える配属済みの社員は何人か
SUM(列)合計給与の総額(人件費)
AVG(列)平均平均給与
MAX(列) / MIN(列)最大 / 最小最高給与と最低給与
sqlite> SELECT COUNT(*) AS 社員数, SUM(salary) AS 給与総額, AVG(salary) AS 平均給与
   ...> FROM employees;
社員数  給与総額  平均給与
------  --------  ----------------
6       2030000   338333.333333333
sqlite> SELECT MAX(salary) AS 最高, MIN(salary) AS 最低 FROM employees;
最高    最低
------  ------
400000  280000

COUNT(*) と COUNT(列) の違い — またNULLの話

ここで第3章のNULLが再登場します。COUNT(*) は行そのものを数えますが、COUNT(列)その列がNULLの行を数えません

sqlite> SELECT COUNT(*) AS 全社員, COUNT(dept_id) AS 配属済み FROM employees;
全社員  配属済み
------  --------
6       5

渡辺さん(dept_id が NULL)は COUNT(dept_id) に数えられていません。この性質は「NULLでない行だけ数えたい」ときには便利ですが、知らずに使うと「社員数が合わない」という混乱のもとになります。AVG(列)も同様にNULLの行を無視して平均します。「NULLを0として平均したい」場合とは結果が変わるので、集計対象の列にNULLがありうるかは常に確認してください。

4.2 GROUP BYでグループ集計

「全社の平均給与」ではなく「部署別の平均給与」が欲しい——そこで GROUP BY です。指定した列の値が同じ行をグループにまとめ、グループごとに集計関数を適用します。

sqlite> SELECT dept_id, COUNT(*) AS 人数, AVG(salary) AS 平均給与
   ...> FROM employees
   ...> GROUP BY dept_id;
dept_id  人数  平均給与
-------  ----  --------
         1     300000.0
10       2     300000.0
20       2     365000.0
30       1     400000.0

6行の社員データが、部署ごとの4行に集約されました。1行目のdept_idが空欄なのは、NULL(配属未定)も1つのグループとしてまとめられているからです。渡辺さん1人のグループです。

⚠️ 鉄則:SELECTに書けるのは「GROUP BYした列」と「集計関数」だけ

GROUP BY使用時、SELECT name, dept_id, COUNT(*) のようにグループ化していない列(name)を混ぜてはいけません。「総務部グループ」には佐藤さんと田中さんの2人がいて、nameとしてどちらを表示すべきか決められないからです。PostgreSQLなど多くのDBMSはこれをエラーにしますが、SQLiteはエラーにせず、どれか1行の値を黙って返します。動いてしまうぶん、間違いに気づきにくい——SQLiteで書くときこそ、この鉄則を自分に課してください。

4.3 HAVING — 集計結果をさらに絞る

「部署別の平均給与のうち、35万円以上の部署だけ見たい」。この条件は平均を計算し終わったあとでないと判定できないので、WHEREでは書けません。集計結果への条件は HAVING に書きます。

sqlite> SELECT dept_id, AVG(salary) AS 平均給与
   ...> FROM employees
   ...> GROUP BY dept_id
   ...> HAVING AVG(salary) >= 350000;
dept_id  平均給与
-------  --------
20       365000.0
30       400000.0

WHEREとHAVINGの違いは実行されるタイミングです。SQLは書いた順ではなく、おおむね次の順で処理されます。

順序やること
1FROM対象のテーブルを用意する(JOINもここ)
2WHEREグループ化の前に、行を絞り込む
3GROUP BY行をグループにまとめる
4HAVINGグループ化の後に、グループを絞り込む
5SELECT表示する列・集計値を確定する
6ORDER BY / LIMIT並べ替えて件数を制限する

「行の条件はWHERE、グループの条件はHAVING」。たとえば「2020年以降入社の社員だけを対象に、部署別平均給与が35万以上の部署」なら、入社日の条件はWHERE(行の絞り込み)、平均の条件はHAVING(グループの絞り込み)に書きます。WHEREで先に行を減らすほうが処理も速くなります。

4.4 JOIN — テーブルを結合する(このコース最大の山場)

ここからがこのコースの最大の山場です。逆に言えば、JOINが分かればリレーショナルデータベースの核心を理解したことになります。

いま社員一覧を表示すると、部署は「10」「20」という数字で出てきます。人間が読みたいのは「総務部」「営業部」という名前です。名前は departments テーブルにあります。2つの表を、dept_id と id の対応でつなげて1つの表にする——これが結合(JOIN)です。

SELECT e.name AS 社員, d.name AS 部署
FROM employees e
INNER JOIN departments d ON e.dept_id = d.id;

新しい書き方を分解します。

DBMSの中で起きることを段階的に見てみましょう。社員の各行について、ON条件に合う部署の行を探して横に並べるイメージです。

employeesの行e.dept_idON条件に合うdepartmentsの行結合結果
佐藤 花子10(10, 総務部)佐藤 花子 × 総務部
鈴木 一郎20(20, 営業部)鈴木 一郎 × 営業部
田中 実10(10, 総務部)田中 実 × 総務部
高橋 美咲30(30, 情報システム部)高橋 美咲 × 情報システム部
伊藤 健20(20, 営業部)伊藤 健 × 営業部
渡辺 由紀NULL相手が見つからない結果から消える

実行結果です。

sqlite> SELECT e.name AS 社員, d.name AS 部署
   ...> FROM employees e
   ...> INNER JOIN departments d ON e.dept_id = d.id;
社員       部署
---------  --------------
佐藤 花子  総務部
鈴木 一郎  営業部
田中 実    総務部
高橋 美咲  情報システム部
伊藤 健    営業部

部署名が表示できました。しかし気づいたでしょうか——社員は6人いるのに、5行しかありません。INNER JOINは「ON条件の相手が両方に見つかった行だけ」を返すため、dept_id がNULLの渡辺さんは相手が見つからず、結果から静かに消えたのです。

4.5 LEFT JOIN — 相手がいなくても残す

「全社員の一覧に部署名を添えたい。配属未定の人も一覧から漏らしたくない」——このときは LEFT JOIN(LEFT OUTER JOIN)を使います。左側(FROMに書いたほう)のテーブルの行は、相手が見つからなくても全部残し、相手側の列にはNULLを入れます。

sqlite> SELECT e.name AS 社員, d.name AS 部署
   ...> FROM employees e
   ...> LEFT JOIN departments d ON e.dept_id = d.id;
社員       部署
---------  --------------
佐藤 花子  総務部
鈴木 一郎  営業部
田中 実    総務部
高橋 美咲  情報システム部
伊藤 健    営業部
渡辺 由紀  

6行になり、渡辺さんが戻ってきました。部署の列はNULL(表示上は空欄)です。

✅ INNERとLEFTの使い分けは、この一問で決まる

相手のいない行を、結果に残したいか?」——残したいならLEFT JOIN、対応が揃っている行だけでよいならINNER JOINです。実務では「全顧客と、注文があれば注文情報」(注文ゼロの顧客も見たい=LEFT)、「注文とその顧客情報」(注文には必ず顧客がいる=INNER)のように、業務の言葉に置き換えて選びます。迷ったらまずLEFT JOINで実行して件数を見る、というのも安全な進め方です。

💡 JOINした結果にも、WHEREやGROUP BYはそのまま使える

結合結果は「1つの大きな表」なので、この章と第3章の技術がすべて適用できます。たとえば「部署別の平均給与」は、JOINしてからGROUP BYすれば1本のSQLで出せます(練習問題4-1)。SQLの部品は組み合わせて使う——これが宣言型言語の気持ちよさです。

4.6 サブクエリ入門 — 問い合わせの入れ子

平均給与より高い給与の社員」を出したいとします。WHERE句に平均額を書きたいのですが、その平均額自体がSELECTしないと分かりません。こんなときは、SQLの中にカッコで別のSQLを埋め込みます。これがサブクエリ(副問い合わせ)です。

sqlite> SELECT name, salary FROM employees
   ...> WHERE salary > (SELECT AVG(salary) FROM employees);
name       salary
---------  ------
鈴木 一郎  350000
高橋 美咲  400000
伊藤 健    380000

カッコの中が先に実行されて平均 338333.33… という1つの値になり、外側は WHERE salary > 338333.33… として評価されます。「まず内側、次に外側」と読むのがコツです。

第3章のINと組み合わせると、「値の一覧」を返すサブクエリも使えます。部署IDを覚えていなくても、部署名から社員を引けます。

sqlite> SELECT name FROM employees
   ...> WHERE dept_id IN (SELECT id FROM departments WHERE name = '営業部');
name
---------
鈴木 一郎
伊藤 健

この例はJOINでも書けます。サブクエリとJOINはしばしば交換可能で、どちらが読みやすいかで選んで構いません。まずは「カッコの中に小さなSELECTを入れられる」という感覚をつかめば十分です。

🛡️ セキュリティ・運用の視点 — 集計はセキュリティ監視の基本技

この章で学んだGROUP BYは、実はセキュリティ監視の現場で毎日使われている技術です。たとえば「IPアドレス別のログイン失敗回数 TOP10」は、攻撃の兆候(パスワード総当たり)を見つける定番のレポートですが、その正体はこういうSQLです。

SELECT ip_address, COUNT(*) AS 失敗回数
FROM access_log
WHERE result = 'FAIL'
GROUP BY ip_address
ORDER BY 失敗回数 DESC
LIMIT 10;

WHEREで失敗ログに絞り、GROUP BYでIPごとにまとめ、ORDER BY+LIMITで上位10件——すべてこの章と第3章の部品です。SIEMと呼ばれる高価な監視製品(セキュリティ中級・上級コースの第7章で扱います)がやっていることの中身も、突き詰めればこの「ログの集計と絞り込み」の発想です。集計SQLが書ける人は、監視ツールのグラフを「読む人」から、検知条件を「作れる人」になれます。

まとめ

練習問題

いずれも company.db に対して実際にSQLを実行し、結果を確かめてください。

問題 4-1

部署別の平均給与を表示し、そのうち平均が35万円以上の部署だけに絞ってください(JOINとGROUP BYとHAVINGの合わせ技です)。

解答を見る
SELECT d.name AS 部署, AVG(e.salary) AS 平均給与
FROM employees e
INNER JOIN departments d ON e.dept_id = d.id
GROUP BY d.name
HAVING AVG(e.salary) >= 350000;

実行結果:

部署            平均給与
--------------  --------
営業部          365000.0
情報システム部  400000.0

処理の流れを実行順序で追うと、①JOINで部署名付きの表を作り(渡辺さんはここで外れる)、②部署名でグループ化し、③HAVINGで平均35万未満のグループ(総務部:平均30万)を落とし、④部署名と平均を表示、となります。総務部が消えた理由を自分の言葉で説明できれば完璧です。

問題 4-2

まず INSERT INTO departments VALUES (40,'経理部'); を実行して、社員が1人もいない経理部を追加してください。そのうえで、すべての部署について「部署名と所属人数」を表示してください。経理部は人数0と表示されること。ヒント:COUNT(*) と COUNT(列) の違いがここで効いてきます。

解答を見る
SELECT d.name AS 部署, COUNT(e.id) AS 人数
FROM departments d
LEFT JOIN employees e ON e.dept_id = d.id
GROUP BY d.id, d.name;

実行結果:

部署            人数
--------------  ----
総務部          2
営業部          2
情報システム部  1
経理部          0

ポイントは2つです。①「社員のいない部署も表示する」ため、departmentsを左側にしたLEFT JOINにします(INNER JOINだと経理部が消えます)。②経理部の行は、結合相手がいないため社員側の列がすべてNULLになります。ここで COUNT(*) を使うと「NULLだらけの1行」も数えて人数1になってしまいます。COUNT(e.id) ならNULLを数えないので、正しく0になります。COUNT(*)とCOUNT(列)の違いが実務で効く典型例です。

問題 4-3

全社の平均給与より高い給与をもらっている社員について、「社員名・給与・部署名」を給与の高い順に表示してください(サブクエリとLEFT JOINの合わせ技。配属未定の社員が該当する場合も漏らさないこと)。

解答を見る
SELECT e.name AS 社員, e.salary AS 給与, d.name AS 部署
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.id
WHERE e.salary > (SELECT AVG(salary) FROM employees)
ORDER BY e.salary DESC;

実行結果:

社員       給与    部署
---------  ------  --------------
高橋 美咲  400000  情報システム部
伊藤 健    380000  営業部
鈴木 一郎  350000  営業部

サブクエリが平均(約338,333円)を計算し、WHEREがそれを超える3人に絞り、LEFT JOINが部署名を添えます。今回のデータでは配属未定の渡辺さん(30万円)は平均以下なので登場しませんが、INNER JOINで書いてしまうと「もし配属未定で高給の社員がいた場合」に黙って漏れます。「漏れてはいけない側をFROMに置いてLEFT JOIN」という守りの書き方を身につけましょう。