LEFT JOIN + カレンダーマスタ — 売上ゼロの日も欠損させずに時系列データを作る
売上データだけを GROUP BY すると、「売上が1件もなかった日」は結果から消滅してしまい、グラフを描画した際にX軸の日付が飛んでしまいます。
これを防ぐため、すべての日付が連続して入っている「カレンダーテーブル」を主軸(左側)にして、売上データを LEFT JOIN します。
FROM calendar AS c LEFT JOIN sales AS s ON c.cal_date = s.sale_date;
NULL になるため、COALESCE(SUM(amount), 0) を使って 0 に変換するのがセットで使われる定石です。10月1日〜10月3日の日付を持つカレンダーテーブル(calendar)と、売上テーブル(sales)があります。
カレンダーテーブルをベースにして売上を LEFT JOIN し、各日付の売上合計(daily_sales)を算出してください。売上がない日は 0 になるようにします。
| cal_date |
|---|
| 10-01 |
| 10-02 |
| 10-03 |
| sale_date | amount |
|---|---|
| 10-01 | 100 |
| 10-01 | 150 |
| 10-03 | 200 |
| cal_date | daily_sales |
|---|---|
| 10-01 | 250 |
| 10-02 | 0 |
| 10-03 | 200 |
SELECT c.cal_date, COALESCE(SUM(s.amount), 0) AS daily_sales -- 売上が無い日は 0 FROM calendar AS c LEFT JOIN sales AS s -- 売上の無い日も残す ON c.cal_date = s.sale_date GROUP BY c.cal_date ORDER BY c.cal_date; /* 実行順序: 1. FROM calendar AS c → カレンダー 2. LEFT JOIN sales AS s → 日付で照合 3. GROUP BY c.cal_date → 展開された仮想テーブルをカレンダーの日付ごとにグループ化 4. SELECT ... → SUM で集計 5. ORDER BY c.cal_date → 日付順に並び替え */
LEGEND
① FROM
FROM calendar AS cデータ欠損を防ぐための「軸」となるカレンダーテーブルを読み込みます。| cal_date |
|---|
| '10-01' |
| '10-02' |
| '10-03' |
c = calendar s = sales
calendar_master を作っておく方法と、PostgreSQLの generate_series() 関数などを使ってクエリ内で動的に生成する(CTEを利用する)方法の2通りがよく使われます。GROUP BY と LEFT JOIN を組み合わせる場合、行が爆発しないように「先にサブクエリで日付ごとにSUMしてからカレンダーと結合する」方がパフォーマンスが良いケースがあります。データ量が増えてきたら実行計画を確認しましょう。LEFT JOIN連鎖の罠 — テーブル結合順序による意図しないデータ消失を防ぐ
ベースとなるテーブルに複数のテーブルを繋げていく際、一度 LEFT JOIN を使って「NULL」を許容した行に対し、その後うっかり INNER JOIN を繋げてしまうと、せっかく残した行が消滅(データ消失)してしまいます。
-- ✗ 誤った連鎖 (orders が無いユーザーが消える) FROM users LEFT JOIN orders ON ... INNER JOIN items ON ... -- ✓ 正しい連鎖 (すべて LEFT JOIN で繋ぐ) FROM users LEFT JOIN orders ON ... LEFT JOIN items ON ...
LEFT JOIN orders によって作られた orders.item_id = NULL の行が、次の INNER JOIN items の ON句評価において「itemsに一致しない」とみなされ、除外されてしまうからです。ユーザー(users)、注文(orders)、商品マスタ(items)の3つのテーブルがあります。
注文履歴がないユーザーも含めて、全員の名前と注文した商品名を出力する正しいSQLを記述してください。注文がない場合、商品名は NULL となります。
| user_id | name |
|---|---|
| 1 | 田中 |
| 2 | 佐藤 |
| 3 | 鈴木 |
| user_id | item_id |
|---|---|
| 1 | 10 |
| 2 | 20 |
| item_id | item_name |
|---|---|
| 10 | PC |
| 20 | マウス |
| name | item_name |
|---|---|
| 田中 | PC |
| 佐藤 | マウス |
| 鈴木 | NULL |
SELECT u.name, i.item_name FROM users AS u LEFT JOIN orders AS o -- ユーザーを全て残しつつ orders と結合 (鈴木は NULL になる) ON u.user_id = o.user_id LEFT JOIN items AS i -- アイテムも LEFT JOIN(INNER だと鈴木が消える) ON o.item_id = i.item_id ORDER BY u.user_id; /* 実行順序: 1. FROM users AS u 2. LEFT JOIN orders AS o 3. LEFT JOIN items AS i → 仮想テーブルと items を結合 4. SELECT u.name, i.item_name → 指定した列を射影 */
LEGEND
① FROM
FROM users AS uすべてのユーザー(3行)をベーステーブルとして読み込みます。| user_id | name |
|---|---|
| 1 | 田中 |
| 2 | 佐藤 |
| 3 | 鈴木 |
u = users o = orders i = items
LEFT JOIN を使っても、その後の結合で INNER JOIN を使ってしまうと、せっかく補完されたNULL行がフィルタリングされて消えてしまいます。実務での「データ件数が合わない」バグの代表格です。LEFT JOIN で繋いでいくのがセーフティな記述です。逆に、フィルタリングを目的とする結合は明示的に INNER JOIN を使います。WHERE i.item_name = 'PC' のように WHERE 句で条件を指定してしまうと、NULL の行(PCを買っていないユーザー)が丸ごと除外されてしまい、実質的に INNER JOIN と同じ挙動になってしまいます(暗黙のINNER JOIN)。条件は ON i.item_name = 'PC' に書く必要があります。LEGEND
① LEFT JOIN 完了時
すべてのテーブルを LEFT JOIN した直後LEFT JOIN によって、注文がない鈴木の行は NULL で補完された状態で残っています。| name | item_name |
|---|---|
| 田中 | 'PC' |
| 佐藤 | 'マウス' |
| 鈴木 | NULL |
最新レコード抽出 (Top-1 JOIN) — サブクエリの集計結果を利用して詳細行を引く
あるグループ(例:ユーザー)ごとに「最新の日付」や「最高スコア」を持つ特定の一行(Top-1)の全カラムを取得したい場面は頻出します。
GROUP BY だけでは集計キー以外の列を取得できないため、サブクエリで最新日付を算出し、それを元のテーブルに複合キーで INNER JOIN してレコード全体を引き当てます。
FROM logs AS a INNER JOIN ( SELECT id, MAX(date) AS max_d FROM logs GROUP BY id ) AS b ON a.id = b.id AND a.date = b.max_d;
ユーザーのログイン履歴テーブル(login_history)があります。同一ユーザーが複数回ログインしています。
各ユーザーの「一番最新のログインレコード」だけを抽出し、user_id、最新日付(login_date)、およびその時の端末(device)を取得してください。
| user_id | login_date | device |
|---|---|---|
| 1 | 10-01 | PC |
| 1 | 10-05 | スマホ |
| 2 | 10-02 | PC |
| 2 | 10-03 | PC |
| user_id | login_date | device |
|---|---|---|
| 1 | 10-05 | スマホ |
| 2 | 10-03 | PC |
SELECT h.user_id, h.login_date, h.device FROM login_history AS h INNER JOIN ( -- サブクエリ: ユーザーごとに最新の日付を計算 SELECT user_id, MAX(login_date) AS max_date FROM login_history GROUP BY user_id ) AS latest ON h.user_id = latest.user_id -- ユーザーIDと日付の両方が一致する行(=最新行)だけを結合・抽出 AND h.login_date = latest.max_date ORDER BY h.user_id; /* 実行順序: 1. [サブクエリ] FROM login_history → テーブル 2. [サブクエリ] GROUP BY user_id → ユーザーごとに MAX 日付を算出 3. FROM login_history AS h → メインのテーブル 4. INNER JOIN ... AS latest ON ... → メインの行とサブクエリ結果を複合キー照合 5. SELECT h.user_id, ... → 必要な列を射影 */
LEGEND
① サブクエリ FROM
FROM login_historyまずサブクエリ内で login_history テーブル(4行)を読み込みます。| user_id | login_date | device |
|---|---|---|
| 1 | '10-01' | 'PC' |
| 1 | '10-05' | 'スマホ' |
| 2 | '10-02' | 'PC' |
| 2 | '10-03' | 'PC' |
h = history sub = MAX日付
SELECT user_id, MAX(date), device ... GROUP BY user_id と書くと、device が GROUP BY に含まれていないため SQLエラーになります。これを突破するためには「集計用」と「詳細取得用」のフェーズを明確に分け、JOINでつなぎ合わせるという発想が必要です。ROW_NUMBER())」を使って解くのが現代のベストプラクティスです。WITH ranked AS (SELECT *, ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY login_date DESC) as rn FROM login_history) SELECT * FROM ranked WHERE rn = 1;このように書くと JOIN すら不要になり、さらに「Top-3まで欲しい」といった拡張も容易になります。しかし、JOINの仕組みを理解しておくことはベースとして非常に重要です。
期間重複チェック — 不等価JOINを活用してダブルブッキングを検知する
会議室の予約システムなどにおいて、「異なる2つの予約の時間が被っているか(重複しているか)」を検知するには、自己結合(SELF JOIN)と不等価結合を組み合わせます。
2つの期間(Start〜End)が重複する条件は論理的に以下の一文で表せます。
A.start < B.end AND A.end > B.start
これを ON 句に記述して JOIN すれば、重複しているペアだけが抽出されます。
SELECT a.id_col, b.id_col FROM table_name a JOIN table_name b -- 同じテーブルを別名で2度使う(自己結合) ON a.group_col = b.group_col -- 同じグループ内で比較する AND a.id_col < b.id_col -- 同じペアを2回出さない AND a.start_col < b.end_col -- 不等価条件で期間の関係を表す AND a.end_col > b.start_col;
会議室の予約テーブル(reservations)があります。
同じ部屋(room_name)で予約時間が被ってしまっている(ダブルブッキングしている)予約のペアを探し、両者の予約ID(res_a, res_b)と部屋名を出力してください。
※自分自身との比較や、(A,B)と(B,A)のような逆順ペアの重複出力を防ぐため、r1.res_id < r2.res_id という条件も加えます。
| res_id | room_name | start_t | end_t |
|---|---|---|---|
| 1 | 会議室A | 10:00 | 12:00 |
| 2 | 会議室A | 11:00 | 13:00 |
| 3 | 会議室A | 13:00 | 14:00 |
| 4 | 会議室B | 10:00 | 12:00 |
| res_a | res_b | room_name |
|---|---|---|
| 1 | 2 | 会議室A |
SELECT r1.res_id AS res_a, r2.res_id AS res_b, r1.room_name FROM reservations AS r1 INNER JOIN reservations AS r2 -- 自分自身のテーブルと結合(SELF JOIN) ON r1.room_name = r2.room_name AND r1.res_id < r2.res_id -- 同一ペアや逆順ペアを排除 AND r1.start_t < r2.end_t -- 期間が被っているかどうかの判定ロジック AND r1.end_t > r2.start_t; /* 実行順序: 1. FROM reservations AS r1 → ベースとして4行を読み込む 2. INNER JOIN reservations AS r2 → 総当たりで組み合わせを生成し、ON句で評価する 3. SELECT r1.res_id, ... → 通過した1ペアだけを射影して出力 */
LEGEND
① FROM
FROM reservations AS r1予約テーブル(4行)を左側のベースとして読み込みます。| res_id | room_name | start_t | end_t |
|---|---|---|---|
| 1 | '会議室A' | '10:00' | '12:00' |
| 2 | '会議室A' | '11:00' | '13:00' |
| 3 | '会議室A' | '13:00' | '14:00' |
| 4 | '会議室B' | '10:00' | '12:00' |
r1 = res (左) r2 = res (右)
r1.res_id < r2.res_id の意図:自己結合を行うと、全く同じ行同士(r1の1とr2の1)も評価されてしまい、当然時間は被っているので出力されてしまいます。また、(1と2)という結果と(2と1)という結果が二重に出るのも防ぐため、IDの大小関係をつけることで「異なるペアの片道切符」だけを評価対象に絞り込んでいます。前日比較 (ズラし結合) — 自己結合で1つ前のデータを横付けして推移を計算する
時系列データにおいて、「昨日の売上」「前回のスコア」など、行をまたぐ比較(前のレコードとの差分など)を行う場合、SQLでは同じテーブルを2つ並べ、結合条件で日付やIDを -1 して「ズラして」結合するというテクニックを使います。
FROM daily_kpi AS t1 -- t2 が「t1の1つ前のデータ」になるように結合する LEFT JOIN daily_kpi AS t2 ON t1.day_id - 1 = t2.day_id;
日々のアクセス数を記録したテーブル(daily_kpi)があります。わかりやすくするため、日付ではなく連番の day_id で管理されています。
このテーブルを自己結合して、今日(today_users)と昨日(yesterday_users)のアクセス数を横に並べて比較し、さらにその差分(diff)を計算して出力してください。
1日目など「前日が存在しない日」の yesterday_users と diff は NULL になります。
| day_id | users |
|---|---|
| 1 | 100 |
| 2 | 120 |
| 3 | 90 |
| day_id | today_users | yesterday_users | diff |
|---|---|---|---|
| 1 | 100 | NULL | NULL |
| 2 | 120 | 100 | 20 |
| 3 | 90 | 120 | -30 |
SELECT t1.day_id, t1.users AS today_users, t2.users AS yesterday_users, t1.users - t2.users AS diff FROM daily_kpi AS t1 LEFT JOIN daily_kpi AS t2 -- 前日のデータが見つからなくても当日の行は残すため LEFT JOIN を使用 ON t1.day_id - 1 = t2.day_id -- t1(今日) から見て 1 を引いたIDが、t2(昨日) のIDと一致するように結合 ORDER BY t1.day_id; /* 実行順序: 1. FROM daily_kpi AS t1 → t1(今日軸)として3行を読み込む 2. LEFT JOIN daily_kpi AS t2 → t1の各行に対し、day_idを1つ減らした値でt2を照合 3. SELECT ... → マッチした行を横に並べ、引き算(diff)を計算して出力 */
LEGEND
① FROM
FROM daily_kpi AS t1今日の軸となる daily_kpi テーブル(3行)を読み込みます。| day_id | users |
|---|---|
| 1 | 100 |
| 2 | 120 |
| 3 | 90 |
t1 = kpi(今日) t2 = kpi(昨日)
DATE 型を使う場合は ON t1.date - INTERVAL '1 day' = t2.date のように日付関数を使ってマイナスします。LAG())を用いて解くのが現代の標準です。SELECT date, users, LAG(users) OVER(ORDER BY date) AS yesterday FROM daily_kpi;このように書けば自己結合は不要になります。ただし、歯抜けデータがある(10/2のレコード自体が存在しない)場合に
LAG() だと10/1の値を引いてしまうため、正確な「前日」を引くためにはやはり 「カレンダーとのJOIN」との合わせ技が必要になります。