N番目のレコード抽出 — ROW_NUMBER(rn=2) と LEFT JOIN で「2回目の購入」を取得する
基礎編では ROW_NUMBER + rn=1 で「最新の1件」を取得しました。応用編では「N番目」の任意の行を取り出します。ポイントは2つ。① ORDER BY の向きが「何番目」の意味を決めること、② 該当行が存在しないユーザーを保持するために LEFT JOIN が必要になることです。
-- ORDER BY の向きで rn の意味が変わる ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY ordered_at DESC) -- rn=1 が「最新」 ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY ordered_at ASC) -- rn=2 が「2回目」
LEFT JOIN ... ON ... AND r.rn = 2 → 購入1件のユーザーも NULL行で保持される。LEFT JOIN ... WHERE r.rn = 2 → NULL行は NULL = 2 が UNKNOWN となり結果から消える(事実上のINNER JOIN化)。すべての顧客について、2回目に購入した商品名(second_product)とその日時(second_ordered_at)を取得してください。購入が1件以下の顧客も NULL で必ず出力すること。
| customer_id | name |
|---|---|
| 1 | 田中 |
| 2 | 佐藤 |
| 3 | 山田 |
| order_id | customer_id | product_name | ordered_at |
|---|---|---|---|
| 101 | 1 | キーボード | 2024-04-01 10:00 |
| 102 | 1 | マウス | 2024-04-03 14:30 |
| 103 | 1 | モニター | 2024-04-10 09:15 |
| 104 | 2 | イヤホン | 2024-04-02 18:00 |
| 105 | 2 | 充電器 | 2024-04-08 12:00 |
| 106 | 3 | ケーブル | 2024-04-05 11:00 |
| name | second_product | second_ordered_at |
|---|---|---|
| 田中 | マウス | 2024-04-03 14:30 |
| 佐藤 | 充電器 | 2024-04-08 12:00 |
| 山田 | NULL | NULL |
完全外部結合(FULL OUTER JOIN) — 2システムの商品マスタを突合して差分を検出する
基幹システムとECサイト——同じ「商品マスタ」が2か所にあるとき、両者のズレ(差分)を見つける作業を突合(とつごう)と呼びます。LEFT JOIN は「左にしかない行」しか保持できませんが、突合では「右にしかない行」も同時に検出する必要があります。そこで登場するのが、両側の行をすべて保持するFULL OUTER JOIN(完全外部結合)です。
-- 結合結果は3つのゾーンに分かれる -- ① 両方に存在(キー一致) → 値の比較が可能 -- ② 左にしか存在しない → 右側の列はすべて NULL -- ③ 右にしか存在しない → 左側の列はすべて NULL FULL OUTER JOIN ... ON a.product_id = b.product_id
片側にしかない行では
a.price <> b.price が「100 <> NULL」となり、TRUE でも FALSE でもなく UNKNOWN と評価されます。WHERE は UNKNOWN の行を通しません。つまり「値の不一致」条件だけでは未登録行を拾えない——突合クエリの最大の落とし穴です。IS NULL 条件を OR で明示的に並べるか、NULL 安全な比較演算子を使います。基幹システムの products_kikan と ECサイトの products_ec を product_id で突合し、差分がある商品だけを抽出してください。差分は3種類——価格不一致 / ECに未登録 / 基幹に未登録——を status 列で示し、両側の価格を並べて出力すること。完全に一致する商品(id=101)は出力しません。
| product_id | name | price |
|---|---|---|
| 101 | ノート | 300 |
| 102 | ペン | 150 |
| 103 | 消しゴム | 100 |
| 104 | 定規 | 200 |
| product_id | name | price |
|---|---|---|
| 101 | ノート | 300 |
| 102 | ペン | 180 |
| 105 | マーカー | 250 |
| product_id | name | price_kikan | price_ec | status |
|---|---|---|---|---|
| 102 | ペン | 150 | 180 | 価格不一致 |
| 103 | 消しゴム | 100 | NULL | ECに未登録 |
| 104 | 定規 | 200 | NULL | ECに未登録 |
| 105 | マーカー | NULL | 250 | 基幹に未登録 |
階層ロールアップ — 自己結合で子部門の売上・目標を親部門に丸め上げる
組織や商品カテゴリの親子関係は、同一テーブル内の自己参照(隣接リストモデル: parent_id が同じテーブルの dept_id を指す)で表現するのが定石です。子の値を親単位に集計する操作をロールアップ(Roll-up)と呼び、自己結合で親の情報を付与してから GROUP BY します。
-- 隣接リストモデル: 1テーブルで親子を表現 departments(dept_id, dept_name, parent_id) -- parent_id = NULL → 親部門(本部)/ parent_id = 1 → dept_id=1 の子部門 -- 子→親のロールアップは「同一テーブルの自己結合」(N:1 なので行数は増えない) FROM departments AS c -- c = 子部門の顔 JOIN departments AS p ON c.parent_id = p.dept_id -- p = 親部門の顔
sales(1:N)と目標 dept_targets(1:1)の両方がぶら下がっています。売上を事前集約せずに両方を同時にJOINすると、目標が売上行数分コピーされて膨張します(ファントラップが階層構造でも発生)。親部門ごとに、配下の子部門の売上合計(total_sales)・目標合計(total_target)・達成率(achievement_rate: %, 小数点1桁)を取得してください。売上を子部門粒度に事前集約してから、自己結合で親部門に丸め上げる正しいアプローチで解いてください。
| dept_id | dept_name | parent_id |
|---|---|---|
| 1 | 営業本部 | NULL |
| 2 | 開発本部 | NULL |
| 11 | 国内営業 | 1 |
| 12 | 海外営業 | 1 |
| 21 | アプリ開発 | 2 |
| 22 | 基盤開発 | 2 |
| sale_id | dept_id | amount |
|---|---|---|
| 1 | 11 | 300000 |
| 2 | 11 | 150000 |
| 3 | 12 | 400000 |
| 4 | 21 | 500000 |
| 5 | 21 | 250000 |
| 6 | 22 | 250000 |
| dept_id | target |
|---|---|
| 11 | 400000 |
| 12 | 500000 |
| 21 | 800000 |
| 22 | 300000 |
| parent_dept | total_sales | total_target | achievement_rate |
|---|---|---|---|
| 営業本部 | 850000 | 900000 | 94.4 |
| 開発本部 | 1000000 | 1100000 | 90.9 |
非等価結合(Non-Equi JOIN) — BETWEEN で契約期間内の利用日数をカウントする
これまでの JOIN はすべて「=」によるキー一致(等価結合)でした。実は ON 句には範囲条件も書けます。これを非等価結合(Non-Equi JOIN)と呼び、「契約期間(月次マスタ)の中に入る利用ログ(日次)」のように、キーの一致ではなく範囲の包含で対応づくテーブル同士を結合できます。
-- 等価結合: キーの一致で対応づける ON s.user_id = l.user_id -- 非等価結合: 等価条件 + 範囲条件で「期間内のログ」だけを対応づける ON s.user_id = l.user_id AND l.used_on BETWEEN s.start_date AND s.end_date
COUNT(*) = 利用回数(イベント粒度: 同日2回は2)。COUNT(DISTINCT l.used_on) = 利用日数(日粒度: 同日2回は1)。さらに LEFT JOIN 時、COUNT(*) は NULL 行も1と数えます(基礎編の罠が再登場)。「利用日数」という日本語をどの集計に翻訳するかが本問の核心です。
月次契約マスタ subscriptions と日次利用ログ usage_logs を結合し、各契約について契約期間内の利用日数(active_days)をカウントしてください。利用ゼロの契約も 0 で出力し、同日複数回の利用は1日と数えること。
| sub_id | user_id | plan | start_date | end_date |
|---|---|---|---|---|
| 1 | 1 | Pro | 2024-04-01 | 2024-04-30 |
| 2 | 2 | Lite | 2024-04-10 | 2024-05-09 |
| 3 | 3 | Pro | 2024-04-15 | 2024-05-14 |
| log_id | user_id | used_on |
|---|---|---|
| 1 | 1 | 2024-04-05 |
| 2 | 1 | 2024-04-05 |
| 3 | 1 | 2024-04-20 |
| 4 | 1 | 2024-04-28 |
| 5 | 1 | 2024-05-02 |
| 6 | 2 | 2024-04-08 |
| 7 | 2 | 2024-04-12 |
| 8 | 2 | 2024-04-25 |
| user_id | plan | active_days |
|---|---|---|
| 1 | Pro | 3 |
| 2 | Lite | 2 |
| 3 | Pro | 0 |
条件付き集約(Conditional Aggregation) — 「AまたはBは買ったが、Cは買っていない」を HAVING で判定する
「コーヒーまたは紅茶を買ったことがある」「ケーキを一度も買っていない」——これらは1行を見ても判定できません。そのユーザーの購買履歴全体(行の集合)に対する条件だからです。WHERE は行を1本ずつ裁く句なので、ここでは GROUP BY でユーザー単位の集合を作り、条件付き集約でその集合の中身を数えて HAVING で判定します。
-- 条件付き集約: 「条件を満たす行だけ」を数える COUNT(*) FILTER (WHERE 条件) -- 標準SQL / PostgreSQL SUM(CASE WHEN 条件 THEN 1 ELSE 0 END) -- 移植版(MySQL等もOK)
WHERE product_name <> 'ケーキ' は「ケーキの行を除く」だけで、「ケーキを買ったユーザーを除く」ではありません。コーヒーとケーキを両方買った人は、コーヒーの行が生き残って結果に混入します。「〜を買っていない」は COUNT(条件付き) = 0 という集合への述語に翻訳するのが本問の核心です。users と purchases を結合し、「コーヒー」または「紅茶」を買ったことがあり、かつ「ケーキ」を一度も買っていないユーザーの user_id と user_name を抽出してください。
| user_id | user_name |
|---|---|
| 1 | 田中 |
| 2 | 佐藤 |
| 3 | 山田 |
| 4 | 鈴木 |
| 5 | 高橋 |
| purchase_id | user_id | product_name |
|---|---|---|
| 1 | 1 | コーヒー |
| 2 | 1 | ケーキ |
| 3 | 2 | コーヒー |
| 4 | 2 | サンドイッチ |
| 5 | 3 | ケーキ |
| 6 | 4 | 紅茶 |
| 7 | 5 | サンドイッチ |
| user_id | user_name |
|---|---|
| 2 | 佐藤 |
| 4 | 鈴木 |