WINDOW関数 — ROW_NUMBER / RANK / SUM OVER でランキングと累計を1クエリで返す
WINDOW関数(ウィンドウ関数)は、行を集約せずに「グループ内での順位」や「累積合計」を各行に付与できる強力な機能です。GROUP BY と異なり、元の行数を保ったまま集計値を追加できます。
ROW_NUMBER() OVER ( -- グループ内で連番(重複なし)を振る PARTITION BY grp_col -- PARTITION BY: グループの区切り(GROUP BY相当) ORDER BY sort_col DESC -- ORDER BY: グループ内での順序を決める ) AS rn RANK() OVER (...) -- 同値に同じ順位(次の順位はスキップ: 1,1,3) DENSE_RANK() OVER (...) -- 同値に同じ順位(スキップなし: 1,1,2) SUM(col) OVER ( -- 累積合計(パーティション内で行ごとに積み上げ) PARTITION BY grp_col ORDER BY sort_col )
sales テーブルから、各商品カテゴリ内での売上ランキング(同売上は同順位)と、カテゴリ内での累積売上を求めてください。カテゴリ・ランキング・累積の順で並べてください。
| sale_id | category | product | amount |
|---|---|---|---|
| 1 | 飲料 | コーヒー | 5000 |
| 2 | 飲料 | お茶 | 3000 |
| 3 | 飲料 | ジュース | 3000 |
| 4 | 食品 | パン | 8000 |
| 5 | 食品 | ケーキ | 6000 |
| 6 | 食品 | クッキー | 4000 |
| category | product | amount | rank_in_cat | running_total |
|---|---|---|---|---|
| 飲料 | コーヒー | 5000 | 1 | 5000 |
| 飲料 | お茶 | 3000 | 2 | 8000 |
| 飲料 | ジュース | 3000 | 2 | 11000 |
| 食品 | パン | 8000 | 1 | 8000 |
| 食品 | ケーキ | 6000 | 2 | 14000 |
| 食品 | クッキー | 4000 | 3 | 18000 |
SELECT category, product, amount, RANK() OVER ( -- カテゴリ内の順位を付ける PARTITION BY category -- カテゴリごとに区切る ORDER BY amount DESC -- 金額の大きい順 ) AS rank_in_cat, -- 同値は同順位・次をスキップ(1,1,3) SUM(amount) OVER ( -- カテゴリ内の累積合計 PARTITION BY category ORDER BY amount DESC, sale_id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW -- 先頭〜現在行の範囲 ) AS running_total -- 累積合計 FROM sales ORDER BY CASE category WHEN '飲料' THEN 1 WHEN '食品' THEN 2 END, rank_in_cat, sale_id; -- 教材で指定したカテゴリ順。同順位はsale_id順 /* 実行順序(WINDOW関数を使ったクエリ): 1. FROM sales → 行を読み込む 2. PARTITION BY category → ウィンドウを分割 3. RANK() OVER (...) → ウィンドウ関数を評価(行数は保持) 4. SUM(amount) OVER (...) → ウィンドウ関数を評価(行数は保持) 5. SELECT → 列を評価 6. ORDER BY CASE category WHEN '飲料' THEN 1 WHEN '食品' THEN 2 END, rank_in_cat, sale_id → 指定カテゴリ順、ランク順、同順位はsale_id順で出力 */
LEGEND
① FROM
FROM salessales テーブル全体(6行)を読み込みます。次のステップで PARTITION BY category によりカテゴリごとにウィンドウを分割します。| sale_id | category | product | amount |
|---|---|---|---|
| 1 | 飲料 | コーヒー | 5000 |
| 2 | 飲料 | お茶 | 3000 |
| 3 | 飲料 | ジュース | 3000 |
| 4 | 食品 | パン | 8000 |
| 5 | 食品 | ケーキ | 6000 |
| 6 | 食品 | クッキー | 4000 |
WHERE rank_in_cat <= 3 を CTEや サブクエリでフィルタすれば実現できる。WINDOW関数を使わずにGROUP BYとJOINで同じ結果を得ようとすると、クエリが非常に複雑になる。WHERE rn = 1 のフィルタと組み合わせてよく使われる。WHERE RANK() OVER (...) <= 3 はエラーになる。WINDOW関数はSELECT/ORDER BYでしか使えない。フィルタしたい場合は CTE または サブクエリで一度ウィンドウ計算をしてから外側でWHEREを書く。3テーブルJOIN — 注文・商品・カテゴリを1クエリで結合して詳細APIを作る
実務のAPIではほとんどの場合、複数のテーブルを結合する必要があります。JOINは連鎖して使えます。結合の種類と結合順序を意識することが重要です。
SELECT a.col, b.col, c.col FROM table_a a -- 起点テーブル INNER JOIN table_b b -- 両方に一致する行のみ結合 ON a.b_id = b.id LEFT JOIN table_c c -- cに一致がなくてもaの行を保持(NULLで補完) ON b.c_id = c.id;
WHERE c.id IS NULL で絞ると「存在しない行だけ抽出」になる。order_items(注文明細)・products(商品)・categories(カテゴリ)の3テーブルを結合して、注文明細に商品名・カテゴリ名を付けて取得してください。カテゴリが設定されていない商品も含めて取得し、その場合は category_name を '未分類' として表示してください。
| item_id | order_id | product_id | qty | price |
|---|---|---|---|---|
| 1 | 101 | P01 | 2 | 500 |
| 2 | 101 | P02 | 1 | 1200 |
| 3 | 102 | P03 | 3 | 300 |
| 4 | 102 | P01 | 1 | 500 |
| product_id | name | category_id |
|---|---|---|
| P01 | コーヒー | C01 |
| P02 | サンドイッチ | C02 |
| P03 | 新商品A | NULL |
| category_id | name |
|---|---|
| C01 | 飲料 |
| C02 | フード |
| item_id | order_id | product_name | category_name | qty | subtotal |
|---|---|---|---|---|---|
| 1 | 101 | コーヒー | 飲料 | 2 | 1000 |
| 2 | 101 | サンドイッチ | フード | 1 | 1200 |
| 3 | 102 | 新商品A | 未分類 | 3 | 900 |
| 4 | 102 | コーヒー | 飲料 | 1 | 500 |
SELECT oi.item_id, oi.order_id, p.name AS product_name, COALESCE(c.name, '未分類') AS category_name, -- 未分類=LEFT JOIN で外れた商品 oi.qty, oi.price * oi.qty AS subtotal -- 小計 = 単価 × 数量 FROM order_items oi INNER JOIN products p -- 商品は必須なので INNER JOIN ON oi.product_id = p.product_id LEFT JOIN categories c -- カテゴリ無しの商品も残す(LEFT JOIN) ON p.category_id = c.category_id ORDER BY oi.item_id; /* 実行順序(3テーブルJOINクエリ): 1. FROM order_items oi → 行を読み込む 2. INNER JOIN products p ON ... → 結合(一致行のみ) 3. LEFT JOIN categories c ON ...→ 結合(左表を全行保持) 4. SELECT → 列を評価(subtotal) 5. ORDER BY oi.item_id → 並び替えて出力 */
LEGEND
① FROM order_items
FROM order_items oi起点テーブル order_items 全体(4行)を読み込みます。product_id をキーに products を INNER JOIN し、さらに category_id をキーに categories を LEFT JOIN します。| item_id | order_id | product_id | qty | price |
|---|---|---|---|---|
| 1 | 101 | P01 | 2 | 500 |
| 2 | 101 | P02 | 1 | 1200 |
| 3 | 102 | P03 | 3 | 300 |
| 4 | 102 | P01 | 1 | 500 |
oi(order_items)、p(products)、c(categories) のように略称を付けることが多い。列名が衝突する場合は oi.name のようにエイリアスで明示する。WHERE c.name = '飲料' と条件を付けると、NULLの行(新商品A)が除外されてINNER JOINと同じ動作になる。LEFT JOINの意味が消えるので要注意。UNION / UNION ALL — 複数クエリを縦に結合してマルチソースのレポートを作る
UNION は複数のSELECT文の結果を縦に結合します。UNION ALL は重複行を含めてすべて結合し、UNION(ALL なし)は重複行を除去します。
SELECT col1, col2 FROM table_a -- 上のSELECT UNION ALL -- 重複を含めてすべて縦に結合(UNION より高速) SELECT col1, col2 FROM table_b; -- 下のSELECT(列数・型を上のSELECTと合わせること) SELECT col1, col2 FROM table_a UNION -- 重複行を除去して縦に結合(内部でDISTINCT処理が走る) SELECT col1, col2 FROM table_b;
異なるテーブルを横に並べたい(列を増やす)ならJOIN、縦に積み上げたい(行を増やす)ならUNIONを使います。
システムには2つの通知テーブルがあります:email_notifications と push_notifications。両テーブルの通知を1つにまとめ、送信日時(sent_at)の新しい順で一覧を返すクエリを作成してください。各行に通知種別('email'/'push')も付与してください。
| id | user_id | subject | sent_at |
|---|---|---|---|
| 1 | U01 | ご注文確認 | 2024-05-10 09:00 |
| 2 | U02 | お知らせ | 2024-05-12 14:00 |
| 3 | U01 | 発送通知 | 2024-05-15 11:00 |
| id | user_id | message | sent_at |
|---|---|---|---|
| 1 | U01 | クーポン配布中! | 2024-05-11 10:00 |
| 2 | U03 | 新着商品入荷 | 2024-05-14 16:00 |
| notification_type | user_id | content | sent_at |
|---|---|---|---|
| U01 | 発送通知 | 2024-05-15 11:00 | |
| push | U03 | 新着商品入荷 | 2024-05-14 16:00 |
| U02 | お知らせ | 2024-05-12 14:00 | |
| push | U01 | クーポン配布中! | 2024-05-11 10:00 |
| U01 | ご注文確認 | 2024-05-10 09:00 |
SELECT 'email' AS notification_type, -- 固定値で種別を付与 user_id, subject AS content, -- 列名を content に統一 sent_at FROM email_notifications UNION ALL -- 縦に結合(重複除去なし=高速) SELECT 'push' AS notification_type, -- 固定値で種別を付与 user_id, message AS content, -- email と列名を揃える sent_at FROM push_notifications ORDER BY sent_at DESC; -- UNION 後の全体を新しい順に /* 実行順序(UNION ALLクエリ): 1. 上の SELECT email_notifications → 行を読み込む 2. 下の SELECT push_notifications → 行を読み込む 3. UNION ALL → 和集合をとる 4. ORDER BY sent_at DESC → 並び替えて出力 */
LEGEND
① 上の SELECT
SELECT 'email' AS notification_type, ... FROM email_notificationsemail_notifications テーブル(3行)を取得し、固定文字列 'email' を notification_type として付与します。subject を content として統一します。| notification_type | user_id | content | sent_at |
|---|---|---|---|
| U01 | ご注文確認 | 2024-05-10 09:00 | |
| U02 | お知らせ | 2024-05-12 14:00 | |
| U01 | 発送通知 | 2024-05-15 11:00 |
'email' AS notification_type のように固定文字列の列を追加することで、結合後にどのテーブルのデータかを識別できる。バッチレポートや監査ログの生成で頻繁に使われるパターン。CAST(amount AS TEXT) や ::TEXT で明示的にキャストして揃えること。SELECT ... FROM a ORDER BY sent_at UNION ALL SELECT ... FROM b は構文エラーまたは意図しない動作になる。ORDER BY は最後のSELECトの後にのみ書くこと。トランザクション — BEGIN / COMMIT / ROLLBACK で複数更新を原子的に処理する
トランザクションは「複数のSQL文をひとまとまりの処理として扱う」仕組みです。BEGIN で開始し、すべて成功したら COMMIT で確定、途中でエラーが起きたら ROLLBACK で全変更を巻き戻します(原子性: Atomicity)。
BEGIN; -- トランザクション開始(START TRANSACTION でも可) UPDATE table_a SET col = val; -- 操作1 INSERT INTO table_b VALUES (...); -- 操作2 -- ここでエラーが起きた場合 ↓ COMMIT; -- 全操作を確定(DBに永続保存) -- または -- ROLLBACK; -- 全操作を取り消す(BEGIN前の状態に戻す)
Eコマースの注文処理(①orders に注文行を INSERT ②order_items に明細を INSERT ③products の在庫数を UPDATE)をトランザクションで1つの原子処理として実装してください。3つの操作のうちどれか1つでも失敗したら全てROLLBACKされるようにしてください。
| order_id | user_id | total_amount | status |
|---|
| order_id | product_id | qty | price |
|---|
| product_id | name | stock |
|---|---|---|
| P01 | コーヒー | 100 |
期待する出力①:
| order_id | user_id | total_amount | status |
|---|---|---|---|
| 1001 | U01 | 1500 | pending |
期待する出力②:
| order_id | product_id | qty | price |
|---|---|---|---|
| 1001 | P01 | 3 | 500 |
期待する出力③:
| product_id | name | stock |
|---|---|---|
| P01 | コーヒー | 97 |
BEGIN; -- トランザクション開始(COMMIT まで未確定) -- ① orders テーブルに注文ヘッダを追加 INSERT INTO orders (order_id, user_id, total_amount, status) VALUES (1001, 'U01', 1500, 'pending'); -- ② order_items テーブルに注文明細を追加 INSERT INTO order_items (order_id, product_id, qty, price) VALUES (1001, 'P01', 3, 500); -- ③ products の在庫を購入数量分だけ減らす UPDATE products SET stock = stock - 3 -- 在庫を3減らす(相対更新) WHERE product_id = 'P01'; -- WHERE 必須(無いと全行更新) COMMIT; -- 全操作成功 → 変更を永続確定(他セッションに反映) -- COMMIT 後の状態を検証 SELECT order_id, user_id, total_amount, status FROM orders ORDER BY order_id; SELECT order_id, product_id, qty, price FROM order_items ORDER BY order_id, product_id; SELECT product_id, name, stock FROM products ORDER BY product_id; /* 実行順序(正常系): 1. BEGIN → トランザクション開始 2. INSERT orders → 行を挿入(未確定) 3. INSERT order_items → 行を挿入(未確定) 4. UPDATE products → 行を更新(未確定) 5. COMMIT → 変更を永続確定 6. SELECT × 3 → 確定後の3テーブルを検証 */
LEGEND
① BEGIN
BEGIN; — トランザクション開始BEGIN でトランザクションを開始します。この後の INSERT/UPDATE はすべて「未確定(コミット待ち)」状態になります。他のセッションからはこの変更が見えません。| ステップ | 操作 | orders | order_items | products.stock |
|---|---|---|---|---|
| START | BEGIN | 変化なし | 変化なし | 100 |
SAVEPOINT sp1; を途中で設定すると ROLLBACK TO SAVEPOINT sp1; でそこまでの変更だけ取り消せる。長いトランザクション内で一部だけ再試行したい場合に使われる(中級テクニック)。INSERT ON CONFLICT (UPSERT) — 冪等なデータ登録でバッチ処理を安全にする
UPSERT(INSERT + UPDATE)は「存在しなければINSERT、すでに存在すればUPDATE」を1文で行う操作です。PostgreSQLでは INSERT ... ON CONFLICT 句で実現します。
INSERT INTO table_name (col1, col2) VALUES ('val1', 'val2') ON CONFLICT (unique_col) -- 一意制約違反が起きた列を指定 DO UPDATE SET -- 競合時に実行するUPDATE処理 col2 = EXCLUDED.col2; -- EXCLUDED = 挿入しようとした新しい値 -- 競合時に何もしない場合: ON CONFLICT (unique_col) DO NOTHING; -- エラーを無視して既存行はそのまま
user_profiles テーブルに外部APIから取得したプロフィールデータを同期する処理を実装してください。user_id が存在しない場合は新規INSERT、すでに存在する場合は name と updated_at だけを更新してください(email は変更しない)。
| user_id(UNIQUE) | name | updated_at | |
|---|---|---|---|
| U01 | 田中 太郎 | tanaka@example.com | 2024-04-01 |
| U02 | 佐藤 花子 | sato@example.com | 2024-04-15 |
| user_id | name | |
|---|---|---|
| U01 | 田中 太郎(改名) | tanaka@example.com |
| U03 | 鈴木 一郎 | suzuki@example.com |
| user_id | name | updated_at | |
|---|---|---|---|
| U01 | 田中 太郎(改名)← 更新 | tanaka@example.com(変化なし) | 2024-06-01(更新) |
| U02 | 佐藤 花子(変化なし) | sato@example.com | 2024-04-15(変化なし) |
| U03 | 鈴木 一郎 ← 新規 | suzuki@example.com | 2024-06-01(新規) |
-- U01 の同期: 既存行 → name と updated_at を更新(emailは変更しない) INSERT INTO user_profiles (user_id, name, email, updated_at) VALUES ( 'U01', '田中 太郎(改名)', 'tanaka@example.com', NOW() -- 同期時刻を設定 ) ON CONFLICT (user_id) -- user_id が重複した(既存行がある)場合の処理 DO UPDATE SET -- 競合したときにUPDATEを実行する name = EXCLUDED.name, -- EXCLUDED: 挿入しようとした新しい値を参照するキーワード updated_at = EXCLUDED.updated_at; -- email は SET に書かないことで元の値を保持する -- U03 の同期: 新規行 → INSERT される(ON CONFLICT は発生しない) INSERT INTO user_profiles (user_id, name, email, updated_at) VALUES ( 'U03', '鈴木 一郎', 'suzuki@example.com', NOW() ) ON CONFLICT (user_id) -- 同じUPSERT構文を書いても、競合なし → 通常のINSERT DO UPDATE SET name = EXCLUDED.name, updated_at = EXCLUDED.updated_at; /* 実行順序(UPSERT / U01 のケース): 1. INSERT INTO user_profiles → 一意制約をチェック 2. 一意制約違反を検出 → 既存行と競合 3. ON CONFLICT DO UPDATE → 既存行を上書き 実行順序(UPSERT / U03 のケース): 1. INSERT INTO user_profiles → 一意制約をチェック 2. 競合なし → 制約に抵触しない 3. 通常のINSERT → 行を挿入 */
LEGEND
① INSERT試行 (U01)
INSERT INTO user_profiles VALUES ('U01', ...)U01(田中 太郎改名)を INSERT しようとします。DB が user_id='U01' の UNIQUE制約をチェックします。U01 は既存行に存在するため ON CONFLICT 句が発動します。| user_id | name | updated_at | |
|---|---|---|---|
| U01(既存) | 田中 太郎 | tanaka@example.com | 2024-04-01 |
| U02(既存) | 佐藤 花子 | sato@example.com | 2024-04-15 |
LEGEND
① INSERT試行 (U03)
INSERT INTO user_profiles VALUES ('U03', ...)U03(鈴木 一郎)を INSERT しようとします。DB が user_id='U03' の UNIQUE制約をチェックします。U03 は存在しないためコンフリクトが発生せず、通常の INSERT として処理されます。| user_id | name | updated_at | |
|---|---|---|---|
| U01 | 田中 太郎(改名) | tanaka@example.com | 2024-06-01 |
| U02 | 佐藤 花子 | sato@example.com | 2024-04-15 |
INSERT ... ON DUPLICATE KEY UPDATE、PostgreSQL は ON CONFLICT ... DO UPDATE)。EXCLUDED.列名 と書くと「今回挿入しようとした新しい値」を参照できる。user_profiles.列名 と書けば「既存行の値」を参照できる。例えば SET click_count = user_profiles.click_count + EXCLUDED.click_count で既存値に加算もできる。ON CONFLICT DO NOTHING はエラーを無視して既存行をそのまま保持する(何も変更しない)。「初回だけINSERT、2回目以降は無視したい」マスタデータ初期化や、重複挿入が起こりうるがエラーにしたくない場合に使う。IF EXISTS(SELECT ...) THEN UPDATE ELSE INSERT のロジックは、マルチスレッド環境でSELECTとINSERTの間に別のスレッドが先にINSERTすると「重複キーエラー」が発生する(TOCTOU競合)。UPSERTなら1文で原子的に処理できるため安全。ON CONFLICT (user_id) は user_id 列にUNIQUE制約または主キー制約がないとエラーになる。ON CONFLICTを使う列には必ずDB側に一意制約を付けること。updated_at = NOW() を必ず更新することで「いつ同期されたか」をトレースでき、障害調査にも役立ちます。