LATERAL JOIN — 各カテゴリの売上TOP3商品を一括取得する
LATERAL は、JOIN の右側のサブクエリが左側の行の列を参照できるようにする構文です。「左側の各行に対して、その行の値を条件にしたサブクエリを実行する」という相関サブクエリのJOIN版です。
SELECT * FROM categories c JOIN LATERAL ( -- 右側のサブクエリが左側の c.category_id を参照できる SELECT * FROM products p WHERE p.category_id = c.category_id -- ← 左側の c を参照(LATERAL の核心) ORDER BY p.revenue DESC LIMIT 3 -- カテゴリごとに上位3件に絞る ) sub ON TRUE; -- ON TRUE: LATERAL は JOIN 条件をサブクエリ内で処理済み
categories テーブルと products テーブルを使い、各カテゴリの売上(revenue)上位3商品をカテゴリ名付きで取得してください。3商品に満たないカテゴリも全件含めること(LEFT JOIN LATERAL)。結果はカテゴリID・売上降順で並べてください。
| category_id | name |
|---|---|
| 1 | 飲料 |
| 2 | 食品 |
| product_id | category_id | name | revenue |
|---|---|---|---|
| P01 | 1 | コーヒー | 50000 |
| P02 | 1 | 紅茶 | 30000 |
| P03 | 1 | 緑茶 | 45000 |
| P04 | 1 | 水 | 20000 |
| P05 | 2 | パン | 25000 |
| P06 | 2 | おにぎり | 18000 |
| category_id | category_name | product_id | product_name | revenue | rank |
|---|---|---|---|---|---|
| 1 | 飲料 | P01 | コーヒー | 50000 | 1 |
| 1 | 飲料 | P03 | 緑茶 | 45000 | 2 |
| 1 | 飲料 | P02 | 紅茶 | 30000 | 3 |
| 2 | 食品 | P05 | パン | 25000 | 1 |
| 2 | 食品 | P06 | おにぎり | 18000 | 2 |
JSONB操作 — PostgreSQLのJSONBカラムを検索・集計・更新する
PostgreSQL の JSONB 型は JSON データをバイナリ形式で格納し、インデックスを使った高速検索が可能です。フレキシブルなデータ構造(ユーザー設定・タグ・メタデータ等)をRDB内で扱うときに使います。
-- JSON値の取り出し(テキスト型 → キャスト可能) col ->> 'key' -- テキストとして取り出す(よく使う) col -> 'key' -- JSONB型のまま取り出す(ネスト参照に続けて使う) col ->> 0 -- JSON配列の index 0 をテキストで取り出す -- 包含チェック(GINインデックスと組み合わせると高速) col @> '{"key": "value"}'::jsonb -- col が指定のキー/値を含むか -- JSONB列の部分更新 jsonb_set(col, '{key}', '"new_value"'::jsonb) -- 指定パスの値を新しい値に置き換える
@> 演算子が高速になります。user_settings テーブルの settings 列(JSONB型)に対して、①通知が有効(notifications = true)なユーザーを抽出するクエリと、②全ユーザーの theme 設定の集計(何人がdark/lightを使っているか)を取得してください。さらに③user_id=1 の language を 'en' → 'ja' に更新してください。
| user_id | settings (JSONB) |
|---|---|
| 1 | {"theme":"dark","language":"en","notifications":true} |
| 2 | {"theme":"light","language":"ja","notifications":false} |
| 3 | {"theme":"dark","language":"ja","notifications":true} |
| 4 | {"theme":"light","language":"en","notifications":true} |
期待する出力①:
| user_id | notifications |
|---|---|
| 1 | true |
| 3 | true |
| 4 | true |
期待する出力②:
| theme | user_count |
|---|---|
| dark | 2 |
| light | 2 |
期待する出力③:
| user_id | settings(更新後) |
|---|---|
| 1 | {"theme":"dark","language":"ja","notifications":true} |
差分更新バッチ — サブクエリで変更分類して INSERT / UPDATE / DELETE を一括実行
外部システムとのデータ同期バッチでは、「全件DELETEしてINSERT」よりも差分のみを更新する方が安全・高速です。サブクエリ(NOT EXISTS等)を使って「追加すべき行・更新すべき行・削除すべき行」を分類し、1トランザクションで実行するパターンを学びます。
-- 新データにあって現データにない → INSERT対象 WHERE NOT EXISTS ( SELECT 1 FROM 現データ c WHERE c.id = n.id ) -- 現データにあって新データにない → DELETE対象 WHERE NOT EXISTS ( SELECT 1 FROM 新データ n WHERE n.id = c.id )
NOT IN はサブクエリの結果に NULL が1つでも含まれると全体が FALSE(または UNKNOWN)になり、意図しない結果を生む罠があります。実務では NOT EXISTS を使うのが安全です。products_current(現在のDB内データ)と products_new(外部システムから取得した最新データ)を比較して、①新規追加すべき商品をINSERTし、②不要になった商品をDELETEし、③変更のあった商品をUPDATEする差分更新バッチを書いてください。
| product_id | name | price |
|---|---|---|
| P01 | コーヒー | 300 |
| P02 | 紅茶 | 250 |
| P03 | 緑茶 | 200 |
| product_id | name | price |
|---|---|---|
| P01 | コーヒー | 350 |
| P02 | 紅茶 | 250 |
| P04 | ほうじ茶 | 220 |
| product_id | name | price |
|---|---|---|
| P01 | コーヒー | 350 |
| P02 | 紅茶 | 250 |
| P04 | ほうじ茶 | 220 |
EXPLAIN ANALYZE — スロークエリを読み解きインデックスで改善する
EXPLAIN ANALYZE はクエリの実行計画と実際の実行時間・行数を出力するコマンドです。「なぜ遅いのか」の原因(インデックス未使用・大量行スキャン等)を特定するための基本ツールです。
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 'U01'; -- 代表的な出力: -- Seq Scan on orders (cost=0.00..12.50 rows=1 width=50) -- ↑全件スキャン(インデックス未使用) -- Index Scan using idx_orders_user_id on orders -- ↑インデックス使用(高速)
| 出力キーワード | 意味 | 対処 |
|---|---|---|
| Seq Scan | 全行スキャン(インデックス未使用) | 検索条件の列にインデックスを作成 |
| Index Scan | インデックスを使って行を特定 | 良い状態 |
| Hash Join | ハッシュを使った JOIN(大テーブル向き) | JOIN キーにインデックスを貼ると Nested Loop に変わることも |
| rows= 推定 vs 実際 | 統計情報の精度 | 大きくズレる場合は ANALYZE でテーブル統計を更新 |
下記の遅いクエリがあります。EXPLAIN で問題を特定し、適切なインデックスを作成してください。さらに複合インデックスが有効なケースも示してください。
| クエリ | 問題 |
|---|---|
| SELECT * FROM orders WHERE user_id = 'U01' | user_id に INDEX なし → Seq Scan |
| SELECT * FROM orders WHERE user_id='U01' AND status='paid' ORDER BY ordered_at DESC | 複合条件 → 複合インデックス未使用 |
| SELECT * FROM products WHERE LOWER(name) = 'coffee' | 関数適用 → インデックス未使用 |
| クエリ | 対処前 | 対処後 |
|---|---|---|
| WHERE user_id = 'U01' | Seq Scan(全件) | Index Scan(高速) |
| WHERE user_id AND status ORDER BY ordered_at | Seq Scan または非効率なIndex Scan | Index Scan(複合インデックス完全活用) |
| WHERE LOWER(name) = 'coffee' | Seq Scan(関数でインデックス無効化) | Index Scan(関数インデックス) |
ROLLUP / CUBE — 多次元集計レポートを1クエリで生成する
ROLLUP は階層的な小計・合計を自動で付加するGROUP BYの拡張です。CUBE は全組み合わせの集計を生成します。GROUPING SETS はそれらを柔軟に指定できる一般化版です。バッチ集計レポートで複数の GROUP BY クエリを1本化できます。
GROUP BY ROLLUP(col1, col2) -- col1+col2の集計、col1だけの小計、全体合計の3段階を自動生成 GROUP BY CUBE(col1, col2) -- (col1,col2), (col1), (col2), () の全4パターンの集計を生成 GROUP BY GROUPING SETS((col1, col2), (col1), ()) -- 任意の集計パターンを列挙する(ROLLUP/CUBEの一般化版)
GROUPING(col1) は小計・合計行で列が集計されていると 1 を返します。これを使って小計行の NULL を '合計' 等の文字列に置き換えることができます。sales テーブルから、年・月・カテゴリ別の売上合計を ROLLUP を使って取得してください。年合計・全体合計(小計行)も含め、GROUPING() 関数で NULL を分かりやすいラベルに置き換えること。さらに同じテーブルで CUBE を使った全組み合わせ集計も示してください。
| sale_id | year | month | category | amount |
|---|---|---|---|---|
| 1 | 2024 | 1 | food | 3000 |
| 2 | 2024 | 1 | drink | 2000 |
| 3 | 2024 | 2 | food | 4000 |
| 4 | 2024 | 2 | drink | 1500 |
| 5 | 2023 | 12 | food | 5000 |
| 6 | 2023 | 12 | drink | 3000 |
期待する出力①:
| year_label | month_label | category_label | total_amount |
|---|---|---|---|
| 2023 | 12 | drink | 3000 |
| 2023 | 12 | food | 5000 |
| 2023 | 12月計 | — | 8000 |
| 2023年計 | — | — | 8000 |
| 2024 | 1 | drink | 2000 |
| 2024 | 1 | food | 3000 |
| 2024 | 1月計 | — | 5000 |
| 2024 | 2 | drink | 1500 |
| 2024 | 2 | food | 4000 |
| 2024 | 2月計 | — | 5500 |
| 2024年計 | — | — | 10500 |
| 総計 | — | — | 18500 |
期待する出力②:
| year | category | total_amount | g_year | g_category |
|---|---|---|---|---|
| 2023 | drink | 3000 | 0 | 0 |
| 2023 | food | 5000 | 0 | 0 |
| 2023 | NULL | 8000 | 0 | 1 |
| 2024 | drink | 3500 | 0 | 0 |
| 2024 | food | 7000 | 0 | 0 |
| 2024 | NULL | 10500 | 0 | 1 |
| NULL | drink | 6500 | 1 | 0 |
| NULL | food | 12000 | 1 | 0 |
| NULL | NULL | 18500 | 1 | 1 |