SELF JOIN — 同一テーブルに2つのエイリアスを付けて「部下と上司」を1クエリで取得する
SELF JOIN(自己結合)とは、同じテーブルに異なるエイリアスを付けて2回 JOIN するテクニックです。1つのテーブルを「部下側(e)」と「上司側(m)」など2つの役割に分けて、同一テーブル内の行同士の関係を取得できます。
FROM employees AS e -- 部下として読み込む INNER JOIN employees AS m -- 同じテーブルを上司として再度 JOIN ON e.manager_id = m.emp_id; -- 部下の manager_id と 上司の emp_id を照合
employees テーブルには manager_id 列があり、自分の上司の emp_id を指しています(社長は NULL)。SELF JOIN を使って全従業員の名前と上司の名前を取得してください(社長は除外)。
| emp_id | name | manager_id |
|---|---|---|
| 1 | 田中(社長) | NULL |
| 2 | 佐藤 | 1 |
| 3 | 山田 | 1 |
| 4 | 鈴木 | 2 |
| employee | manager |
|---|---|
| 佐藤 | 田中(社長) |
| 山田 | 田中(社長) |
| 鈴木 | 佐藤 |
SELECT e.name AS employee, -- 部下側 (e) の名前 m.name AS manager -- 上司側 (m) の名前 FROM employees AS e -- e: 部下として読み込む(4行) INNER JOIN employees AS m -- m: 上司として同じテーブルをもう一度 JOIN ON e.manager_id = m.emp_id -- 部下の manager_id と 上司の emp_id が一致する行のみ ORDER BY e.emp_id; /* 実行順序: 1. FROM employees AS e → 部下として読み込む 2. INNER JOIN employees AS m → 上司として自己結合 3. SELECT e.name, m.name → 2列を射影 */
LEGEND
① FROM
FROM employees AS eemployees テーブル(4行)を「部下(e)」として読み込みます。manager_id 列が SELF JOIN のキーになります。田中(emp_id=1)は manager_id = NULL のため、INNER JOIN 条件を満たさず次のステップで除外されます。| emp_id | name | manager_id |
|---|---|---|
| 1 | 田中(社長) | NULL |
| 2 | 佐藤 | 1 |
| 3 | 山田 | 1 |
| 4 | 鈴木 | 2 |
ON e.manager_id = m.emp_id で照合するとき、田中の manager_id = NULL はどの値とも一致しない(NULL = 1 は UNKNOWN)ため、INNER JOIN の結果から除外されます。「社長も含めて全員を取得したい」場合は LEFT JOIN に変えると、田中の manager 列が NULL で出力されます。FROM employees INNER JOIN employees ON ... のようにエイリアスを省略すると、name などの列が「どちらの employees の name か」が曖昧でエラーになります。SELF JOIN では必ず両方にエイリアスを付けることが必須です。AS a, AS b のような無意味なエイリアスより、AS e(employee), AS m(manager) のように役割を示す名前を使いましょう。コードレビュー時に「どちらが部下でどちらが上司か」が即座に分かります。categories テーブルで parent_id を持つ木構造)、③ SNSのフォロー/フォロワー(同じ users テーブルの user_id 同士を follows テーブルで結ぶ)。「1つのテーブルの中に関係がある」データを扱うときは SELF JOIN を思い出してください。LEFT JOIN の ON vs WHERE — フィルタをON句に書くかWHERE句に書くかで結果が変わる
LEFT JOIN と組み合わせるとき、フィルタ条件をどこに書くかで結果が変わります。これは実務で最もよく起きるバグの1つです。
-- ✓ ON句フィルタ: 全ユーザー保持 + shipped 注文のみ結合 LEFT JOIN orders AS o ON u.user_id = o.user_id AND o.status = 'shipped' ← 結合の条件として機能。左テーブル全行を保持 -- ✗ WHERE句フィルタ: NULL行が除外されINNER JOINと同等になる LEFT JOIN orders AS o ON u.user_id = o.user_id WHERE o.status = 'shipped' ← NULLは比較でFALSEになり、注文なし行が消える
users テーブルの全ユーザーと、各ユーザーの status = 'shipped' の注文情報を取得してください。shipped 注文がないユーザーや注文自体がないユーザーは NULL で表示します。ON句に AND o.status = 'shipped' を追加して実装してください。
※ 田中(user_id=1)には shipped 注文(id=1)と pending 注文(id=2)の両方があります。pending 注文は ON条件を満たさないため NULL扱いになります。
| user_id | name |
|---|---|
| 1 | 田中 |
| 2 | 佐藤 |
| 3 | 山田 |
| order_id | user_id | status |
|---|---|---|
| 1 | 1 | shipped |
| 2 | 1 | pending |
| 3 | 2 | shipped |
| name | order_id | status |
|---|---|---|
| 田中 | 1 | shipped |
| 佐藤 | 3 | shipped |
| 山田 | NULL | NULL |
SELECT u.name, o.order_id, o.status FROM users AS u LEFT JOIN orders AS o ON u.user_id = o.user_id -- 結合キー AND o.status = 'shipped' -- ON句フィルタ: shipped のみ結合対象(左行保持は維持) ORDER BY u.user_id, o.order_id NULLS LAST; -- ↑ AND を WHERE に書くと間違い: -- WHERE o.status = 'shipped' にすると NULL行(山田)も除外され -- INNER JOIN と同じ結果になってしまう。 /* 実行順序: 1. FROM users AS u → 左テーブルを読み込む 2. LEFT JOIN orders AS o → ON(user_id 一致 AND status='shipped')で結合 3. SELECT u.name, o.order_id, o.status → 列を射影 */
LEGEND
① FROM
FROM users AS uusers テーブル(3行)を左テーブルとして読み込みます。LEFT JOIN のため、この3行は結果に必ず含まれます。| user_id | name |
|---|---|
| 1 | 田中 |
| 2 | 佐藤 |
| 3 | 山田 |
o.status = NULL)はどんな条件とも一致しないため除外されます。LEFT JOIN の「全行保持」を活かしたいなら、右テーブルへの条件は ON句に書くのが原則です。WHERE o.order_id IS NULL は意図的に NULL行だけを残す使い方でした。本問のように「右テーブルの値で絞り込みたいが左テーブルは全件欲しい」場合は ON句に書きます。「全ユーザー保持しつつ条件付きで結合」→ ON句、「結合後に絞り込む(差集合含む)」→ WHERE句と使い分けます。... LEFT JOIN orders AS o ON u.user_id = o.user_id WHERE o.status = 'shipped' と書くと、山田(NULL)と田中のpending行はどちらも NULL = 'shipped' が FALSE になり除外されます。結果は田中(shipped)・佐藤(shipped)の2行のみ、つまり INNER JOIN と同じ結果です。LEFT JOIN を使う意味がなくなります。ON u.user_id = o.user_id AND o.created_at >= NOW() - INTERVAL '30 days'。30日以内に注文がないユーザーも NULL で表示できます。WHERE に書いてしまうと「30日以内に注文したユーザーのみ」になってしまいます。ダッシュボードで「全会員のアクティビティ」を表示するAPIでは特に重要な知識です。COALESCE + LEFT JOIN — LEFT JOINのNULLをCOALESCEでデフォルト値に変換する
LEFT JOIN で NULL になった列をそのままAPIレスポンスに含めると、クライアント側で都度 null チェックが必要になります。COALESCE 関数を使うと、SQL側で NULL をデフォルト値に変換できます。
COALESCE(expr, default_value) -- expr が NULL なら default_value を返す。NULL でなければ expr をそのまま返す。 -- 複数引数も可: COALESCE(a, b, c) → 左から最初の非NULL値を返す
SELECT u.name, COALESCE(pr.bio, '未設定') AS bio, -- NULL → '未設定' COALESCE(pr.avatar_url, '/default.png') AS avatar -- NULL → '/default.png' FROM users AS u LEFT JOIN profiles AS pr ON u.user_id = pr.user_id;
IFNULL(expr, default) がありますが、COALESCE はSQL標準でPostgreSQL・MySQL・SQLiteなど全対応です。実務ではCOALESCE を使う方がポータブルです。users テーブルの全ユーザーと、各ユーザーのプロフィール情報(bio・avatar_url)を取得してください。プロフィールが未登録のユーザーは、bio を '未設定'、avatar_url を '/img/default.png' で表示してください。
| user_id | name |
|---|---|
| 1 | 田中 |
| 2 | 佐藤 |
| 3 | 山田 |
| user_id | bio | avatar_url |
|---|---|---|
| 1 | SQLが得意 | /img/tanaka.png |
| 2 | デザイナー | /img/sato.png |
| name | bio | avatar_url |
|---|---|---|
| 田中 | SQLが得意 | /img/tanaka.png |
| 佐藤 | デザイナー | /img/sato.png |
| 山田 | 未設定 | /img/default.png |
SELECT u.name, COALESCE(pr.bio, '未設定') AS bio, -- pr.bio が NULL(プロフィール未登録)なら '未設定' を返す COALESCE(pr.avatar_url, '/img/default.png') AS avatar_url -- pr.avatar_url が NULL なら '/img/default.png' を返す FROM users AS u LEFT JOIN profiles AS pr -- プロフィールが未登録でも users 全行を保持 ON u.user_id = pr.user_id ORDER BY u.user_id; /* 実行順序: 1. FROM users AS u → 全ユーザー3行を読み込む 2. LEFT JOIN profiles AS pr → ON条件で照合 3. SELECT + COALESCE(...) → 各行に COALESCE を適用 */
LEGEND
① FROM
FROM users AS uusers テーブル(3行)を左テーブルとして読み込みます。LEFT JOIN のため全ユーザーが結果に含まれます。| user_id | name |
|---|---|
| 1 | 田中 |
| 2 | 佐藤 |
| 3 | 山田 |
COALESCE は引数を左から順に評価し、最初に NULL でない値を返します。COALESCE(pr.bio, '未設定') は「pr.bio が NULL でなければ pr.bio を、NULL ならば '未設定' を返す」という意味です。複数のフォールバックも可能で、COALESCE(pr.bio, u.name, '未設定') のように書けます。COALESCE(count_col, '0') のように整数列に文字列を渡すと型エラーになります(PostgreSQL)。数値列なら COALESCE(count_col, 0)、文字列列なら COALESCE(str_col, '') と型を合わせましょう。NOT NULL 制約がある列に COALESCE を付けても意味がありません。COALESCE はNULL になりうる列(LEFT JOINの結果や NULLABLEな列)に使うものです。不要な COALESCE はクエリを読みにくくするだけです。WHERE COALESCE(col, 0) = 0 のように WHERE 句でインデックス列を COALESCE で包むと、インデックスが使われなくなります(関数を適用するとインデックス列が評価できない)。フィルタ条件では WHERE col IS NULL OR col = 0 のように書くのが適切です。COALESCE(SUM(amount), 0) AS total)、③ 多言語対応(COALESCE(t.ja, t.en, 'N/A') AS label で日本語なければ英語を返す)。SQL側でデフォルト値を処理することで、アプリコードの null チェックを大幅に削減できます。JOIN + HAVING — GROUP BY で集計した後に条件で絞り込む(WHERE との実行順序の違い)
HAVING は GROUP BY の後で集計結果に対して条件を指定するフィルタです。WHERE は集計前(行レベル)のフィルタなので、集計関数(COUNT・SUM など)を WHERE に書くとエラーになります。
SELECT u.name, COUNT(o.order_id) AS order_count FROM users AS u INNER JOIN orders AS o ON u.user_id = o.user_id GROUP BY u.user_id, u.name HAVING COUNT(o.order_id) >= 2; -- 集計後の絞り込み
FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY。HAVING は GROUP BY の後なので集計関数が使えます。WHERE は GROUP BY 前なので集計関数は使えません(WHERE COUNT(...) >= 2 は構文エラー)。users テーブルと orders テーブルを結合して、注文件数が2件以上のユーザーの名前と注文件数を取得してください。出力は注文件数の降順で並べてください。
| user_id | name |
|---|---|
| 1 | 田中 |
| 2 | 佐藤 |
| 3 | 山田 |
| order_id | user_id | amount |
|---|---|---|
| 1 | 1 | 4200 |
| 2 | 1 | 1800 |
| 3 | 2 | 3500 |
| 4 | 3 | 2000 |
| 5 | 3 | 900 |
| name | order_count |
|---|---|
| 田中 | 2 |
| 山田 | 2 |
SELECT u.name, COUNT(o.order_id) AS order_count -- 各グループの注文件数を集計 FROM users AS u INNER JOIN orders AS o ON u.user_id = o.user_id GROUP BY u.user_id, u.name -- SELECT の非集計列を全列指定 HAVING COUNT(o.order_id) >= 2 -- GROUP BY 後の集計値に条件をかける(WHERE は不可) ORDER BY order_count DESC, u.user_id; -- 同件数はuser_id昇順で確定 /* 実行順序: 1. FROM users AS u → users の3行を読み込む 2. INNER JOIN orders AS o → ON条件で結合 → 仮想テーブル5行 3. WHERE → (なし) 4. GROUP BY u.user_id → 5行を3グループに分類(田中2行・佐藤1行・山田2行) 5. HAVING COUNT >= 2 → 各グループの COUNT を評価: 佐藤(COUNT=1)を除外 6. SELECT u.name, COUNT(...) → 残り2グループを射影 7. ORDER BY order_count DESC, u.user_id → 件数降順、同件数はuser_id昇順 */
LEGEND
① FROM
FROM users AS uusers テーブル(3行)を左テーブルとして読み込みます。| user_id | name |
|---|---|
| 1 | 田中 |
| 2 | 佐藤 |
| 3 | 山田 |
FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY です。HAVING は GROUP BY の後に実行されるため、集計関数(COUNT・SUM・AVG など)を条件に使えます。WHERE は GROUP BY 前なので集計関数は使えません。category = 'food' のような行レベルの条件は HAVING ではなく WHERE に書くのが正しく、効率的です。WHERE で先に絞り込んでから GROUP BY すると集計対象の行数が減り、パフォーマンスが向上します。WHERE COUNT(o.order_id) >= 2 は GROUP BY の前に評価されるため、集計が未完了の状態で集計関数を呼ぶことになり構文エラーになります。集計後の絞り込みは必ず HAVINGを使います。SELECT COUNT(...) AS order_count ... HAVING order_count >= 2 の order_count エイリアスは PostgreSQL では HAVING で使えません(GROUP BY では一部使えます)。安全のため HAVING では集計式をそのまま再記述するか、サブクエリで包みます。HAVING COUNT(*) >= 2)。特にデータ品質チェックやセグメント分析では HAVING は必須の構文です。CROSS JOIN — ON句なしで全行×全行の組み合わせ(直積)を生成する
CROSS JOIN(交差結合)は ON句を指定せず、左テーブルの全行と右テーブルの全行のすべての組み合わせを生成します。結果の行数は「左テーブルの行数 × 右テーブルの行数」です。
FROM colors AS c CROSS JOIN sizes AS s; -- ON句を書かない。colors 3行 × sizes 3行 = 9行を生成する
商品バリアント(色とサイズの全組み合わせ)を生成してください。colors テーブル(3色)と sizes テーブル(3サイズ)を CROSS JOIN して、color_name と size_name の全組み合わせ(合計9行)を取得してください。
| color_id | color_name |
|---|---|
| 1 | レッド |
| 2 | ブルー |
| 3 | グリーン |
| size_id | size_name |
|---|---|
| 1 | S |
| 2 | M |
| 3 | L |
| color_name | size_name |
|---|---|
| レッド | S |
| レッド | M |
| レッド | L |
| ブルー | S |
| ブルー | M |
| ブルー | L |
| グリーン | S |
| グリーン | M |
| グリーン | L |
SELECT c.color_name, s.size_name FROM colors AS c CROSS JOIN sizes AS s -- ON句なし: c の全3行 × s の全3行 = 9通りを生成 ORDER BY c.color_id, s.size_id; -- 色・サイズの順に並べる /* 実行順序: 1. FROM colors AS c → colors の3行を読み込む 2. CROSS JOIN sizes AS s → ON条件なし。colors の各行に対して sizes の全3行を結合。 レッド × (S, M, L) → 3行 ブルー × (S, M, L) → 3行 グリーン × (S, M, L) → 3行 合計 3 × 3 = 9行の仮想テーブルを生成 3. SELECT c.color_name, s.size_name → 2列を射影 4. ORDER BY c.color_id, s.size_id → 色・サイズ順に並べ替え */
LEGEND
① FROM
FROM colors AS ccolors テーブル(3行)を左テーブルとして読み込みます。CROSS JOIN では各行が右テーブルの全行と組み合わさります。| color_id | color_name |
|---|---|
| 1 | レッド |
| 2 | ブルー |
| 3 | グリーン |
FROM colors, sizes(カンマ区切り)は暗黙の CROSS JOIN です。SQL-89 の古い記法で、現代では CROSS JOIN キーワードを明示的に書く方が意図が明確です。また、古い記法の FROM カンマ区切りで WHERE を書き忘れると意図せず CROSS JOIN になるバグの原因にもなります。INNER JOIN table2 と書いて ON句を省略すると、DBによっては CROSS JOIN として実行されます(PostgreSQL ではエラー)。多テーブルJOINで行数が急増したときは「ON句を書き忘れていないか」を最初に確認しましょう。generate_series(1,12) CROSS JOIN generate_series(1,31) で月×日のカレンダーマトリクス)、③ A/Bテストの組み合わせ生成(パターンA×条件Bの全テストケース)。「何も持っていないゼロから組み合わせを作りたい」とき、CROSS JOIN は強力な武器になります。