SQL カーディナリティ — FULL OUTER JOIN・FILTERの応用

応用カーディナリティROW_NUMBERFULL OUTER JOINFILTERPostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 6

N番目のレコード抽出 — ROW_NUMBER(rn=2) と LEFT JOIN で「2回目の購入」を取得する

ROW_NUMBER応用1:N→0..1LEFT JOINON句の追加条件
前提知識

基礎編では 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回目」
rn = 2 をどこに書くか — ON句 と WHERE句 の決定的な違い:
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 で必ず出力すること。

使用テーブル
▸ customers
customer_idname
1田中
2佐藤
3山田
▸ orders
order_idcustomer_idproduct_nameordered_at
1011キーボード2024-04-01 10:00
1021マウス2024-04-03 14:30
1031モニター2024-04-10 09:15
1042イヤホン2024-04-02 18:00
1052充電器2024-04-08 12:00
1063ケーブル2024-04-05 11:00
期待出力
namesecond_productsecond_ordered_at
田中マウス2024-04-03 14:30
佐藤充電器2024-04-08 12:00
山田NULLNULL
QUESTION 7

完全外部結合(FULL OUTER JOIN) — 2システムの商品マスタを突合して差分を検出する

FULL OUTER JOINCOALESCE差分検出3値論理(NULL比較)
前提知識

基幹システムとECサイト——同じ「商品マスタ」が2か所にあるとき、両者のズレ(差分)を見つける作業を突合(とつごう)と呼びます。LEFT JOIN は「左にしかない行」しか保持できませんが、突合では「右にしかない行」も同時に検出する必要があります。そこで登場するのが、両側の行をすべて保持するFULL OUTER JOIN(完全外部結合)です。

-- 結合結果は3つのゾーンに分かれる
--   ① 両方に存在(キー一致)     → 値の比較が可能
--   ② 左にしか存在しない         → 右側の列はすべて NULL
--   ③ 右にしか存在しない         → 左側の列はすべて NULL
FULL OUTER JOIN ... ON a.product_id = b.product_id
NULL との比較は UNKNOWN(3値論理):
片側にしかない行では a.price <> b.price が「100 <> NULL」となり、TRUE でも FALSE でもなく UNKNOWN と評価されます。WHERE は UNKNOWN の行を通しません。つまり「値の不一致」条件だけでは未登録行を拾えない——突合クエリの最大の落とし穴です。IS NULL 条件を OR で明示的に並べるか、NULL 安全な比較演算子を使います。
問題

基幹システムの products_kikan と ECサイトの products_ecproduct_id で突合し、差分がある商品だけを抽出してください。差分は3種類——価格不一致 / ECに未登録 / 基幹に未登録——を status 列で示し、両側の価格を並べて出力すること。完全に一致する商品(id=101)は出力しません。

使用テーブル
▸ products_kikan
product_idnameprice
101ノート300
102ペン150
103消しゴム100
104定規200
▸ products_ec
product_idnameprice
101ノート300
102ペン180
105マーカー250
期待出力
product_idnameprice_kikanprice_ecstatus
102ペン150180価格不一致
103消しゴム100NULLECに未登録
104定規200NULLECに未登録
105マーカーNULL250基幹に未登録
QUESTION 8

階層ロールアップ — 自己結合で子部門の売上・目標を親部門に丸め上げる

自己結合階層構造事前集約ファントラップ回避
前提知識

組織や商品カテゴリの親子関係は、同一テーブル内の自己参照(隣接リストモデル: 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桁)を取得してください。売上を子部門粒度に事前集約してから、自己結合で親部門に丸め上げる正しいアプローチで解いてください。

使用テーブル
▸ departments
dept_iddept_nameparent_id
1営業本部NULL
2開発本部NULL
11国内営業1
12海外営業1
21アプリ開発2
22基盤開発2
▸ sales
sale_iddept_idamount
111300000
211150000
312400000
421500000
521250000
622250000
▸ dept_targets
dept_idtarget
11400000
12500000
21800000
22300000
期待出力
parent_depttotal_salestotal_targetachievement_rate
営業本部85000090000094.4
開発本部1000000110000090.9
QUESTION 9

非等価結合(Non-Equi JOIN) — BETWEEN で契約期間内の利用日数をカウントする

非等価結合BETWEENCOUNT(DISTINCT)ON句の範囲条件
前提知識

これまでの 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日と数えること。

使用テーブル
▸ subscriptions
sub_iduser_idplanstart_dateend_date
11Pro2024-04-012024-04-30
22Lite2024-04-102024-05-09
33Pro2024-04-152024-05-14
▸ usage_logs
log_iduser_idused_on
112024-04-05
212024-04-05
312024-04-20
412024-04-28
512024-05-02
622024-04-08
722024-04-12
822024-04-25
期待出力
user_idplanactive_days
1Pro3
2Lite2
3Pro0
QUESTION 10

条件付き集約(Conditional Aggregation) — 「AまたはBは買ったが、Cは買っていない」を HAVING で判定する

条件付き集約HAVINGFILTER / CASE WHEN集合への述語
前提知識

「コーヒーまたは紅茶を買ったことがある」「ケーキを一度も買っていない」——これらは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 という集合への述語に翻訳するのが本問の核心です。
問題

userspurchases を結合し、「コーヒー」または「紅茶」を買ったことがあり、かつ「ケーキ」を一度も買っていないユーザーの user_iduser_name を抽出してください。

使用テーブル
▸ users
user_iduser_name
1田中
2佐藤
3山田
4鈴木
5高橋
▸ purchases
purchase_iduser_idproduct_name
11コーヒー
21ケーキ
32コーヒー
42サンドイッチ
53ケーキ
64紅茶
75サンドイッチ
期待出力
user_iduser_name
2佐藤
4鈴木