UPDATE + WHERE — バッチでステータスを一括更新する安全パターン
UPDATE は既存の行の列値を書き換えます。WHERE で必ず更新対象を絞り込むことが最重要です。WHERE を省略すると全行が書き換わります。
UPDATE table_name -- 更新対象テーブル SET col1 = 'new_value', -- 更新する列と値(複数列はカンマ区切り) col2 = NOW() -- 複数列を同時に更新できる WHERE id = 1; -- 必須!省略すると全行更新(壊滅的バグ)
バッチ処理でよくあるパターンは「一定期間アクセスのないユーザーを inactive に一括変更」「期限切れトークンを無効化」などです。
UPDATE users SET status = 'inactive' は全ユーザーが inactive になる。本番環境では取り返しがつかない。UPDATE の前に必ず WHERE の条件を確認すること。subscriptions テーブルで、end_date が今日以前(期限切れ)かつ status が 'active' の契約を、status='expired' に一括更新してください。更新と同時に updated_at を現在時刻に更新し、更新した行の subscription_id と status を RETURNING で返してください。
| subscription_id | user_id | status | end_date | updated_at |
|---|---|---|---|---|
| 1 | U01 | active | 2024-05-20 | 2024-04-01 |
| 2 | U02 | active | 2024-06-30 | 2024-05-01 |
| 3 | U03 | active | 2024-05-31 | 2024-04-15 |
| 4 | U04 | expired | 2024-04-01 | 2024-04-02 |
| 5 | U05 | active | 2024-07-15 | 2024-05-10 |
| subscription_id | status |
|---|---|
| 1 | expired |
| 3 | expired |
UPDATE subscriptions SET status = 'expired', updated_at = NOW() WHERE -- UPDATE は WHERE 必須(全行更新を防ぐ) end_date <= DATE '2024-06-01' -- 問題文の現在日を固定 AND status = 'active' -- 有効な行のみ(無駄な更新を防ぐ) RETURNING subscription_id, status; -- 更新された行を返す /* 実行順序(SQLの論理的な評価順): 1. FROM subscriptions → 行を読み込む 2. WHERE → 行を絞り込む 3. SET status='expired'→ 列の値を更新 4. RETURNING → 更新後の値を返す */
LEGEND
① FROM
FROM subscriptionssubscriptions テーブル全5行を読み込みます。現在日は 2024-06-01 です。| subscription_id | user_id | status | end_date | updated_at |
|---|---|---|---|---|
| 1 | U01 | active | 2024-05-20 | 2024-04-01 |
| 2 | U02 | active | 2024-06-30 | 2024-05-01 |
| 3 | U03 | active | 2024-05-31 | 2024-04-15 |
| 4 | U04 | expired | 2024-04-01 | 2024-04-02 |
| 5 | U05 | active | 2024-07-15 | 2024-05-10 |
WHERE end_date <= CURRENT_DATE AND status='active' を毎晩実行する設計が最頻出。CRON + このUPDATE + RETURNINGで更新件数をログに記録するのが実務標準。AND status='active' の条件を付けることで「何度実行しても結果が変わらない」べき等なバッチになる。バッチが2重実行されても問題が起きない設計が重要。CURRENT_DATE は日付のみ(時刻なし)、NOW() はタイムスタンプ(日付+時刻)。日付比較には CURRENT_DATE、更新日時の記録には NOW() を使い分ける。UPDATE subscriptions SET status='expired' は全5件が expired になる。本番では絶対に起こしてはならないミス。UPDATE前には必ず SELECT COUNT(*) FROM subscriptions WHERE ... で対象件数を確認する習慣をつける。SET stats = 'expired'(status のタイポ)は列が存在しないためエラーになるが、テーブルに同名の別列があった場合は意図しない列を書き換えてしまう。SQL実行前にレビューを必ずすること。AND status = 'active' のように、すでに処理済みのデータは対象外とする絞り込みを入れることで、2重実行されても安全なSQLになります。UPDATEバッチを書く時は「途中で止まって再実行されたらどうなるか?」を常に想像しましょう。
DELETE vs 論理削除 — APIで安全にデータを「消す」設計パターン
DELETE はテーブルから行を物理的に削除します。一方、論理削除は行を消さず deleted_at 列に削除日時を記録するパターンで、API開発では論理削除が主流です。
-- 物理削除(完全に消える・元に戻せない) DELETE FROM table_name -- 対象テーブル WHERE id = 1; -- 必須!省略すると全行削除 -- 論理削除(deleted_at に日時を入れるだけ、行は残る) UPDATE table_name SET deleted_at = NOW() -- NULL → 現在日時 に変更することで「削除済み」を表現 WHERE id = 1 AND deleted_at IS NULL; -- まだ削除されていない行のみ(べき等性の確保)
WHERE deleted_at IS NULL を常に付ける必要がある。comments テーブルで、comment_id=3 のコメントを論理削除してください(deleted_at に現在時刻を記録)。また、論理削除済みを除いた有効なコメント一覧を取得するSQLも書いてください。
| comment_id | post_id | body | deleted_at |
|---|---|---|---|
| 1 | 10 | 素晴らしい記事です | NULL |
| 2 | 10 | 参考になりました | NULL |
| 3 | 11 | 不適切なコメント | NULL |
| 4 | 11 | ありがとうございます | 2024-05-01 |
| comment_id | post_id | body | deleted_at |
|---|---|---|---|
| 1 | 10 | 素晴らしい記事です | NULL |
| 2 | 10 | 参考になりました | NULL |
-- ① 論理削除: comment_id=3 の deleted_at に現在時刻を記録 UPDATE comments SET deleted_at = NOW() -- 削除日時をセット=論理削除(DELETE しない) WHERE comment_id = 3 AND deleted_at IS NULL; -- 未削除の行だけ -- ② 有効コメント一覧取得: deleted_at が NULL の行のみ返す SELECT comment_id, post_id, body, deleted_at FROM comments WHERE deleted_at IS NULL -- 未削除(IS NULL で判定) ORDER BY comment_id ASC; /* 実行順序(SQLの論理的な評価順 / ②の一覧取得): 1. FROM comments → 行を読み込む 2. WHERE deleted_at IS NULL → 行を絞り込む 3. SELECT → 列を評価 4. ORDER BY comment_id → 並び替えて出力 */
LEGEND
① FROM
FROM comments — 論理削除前comments テーブル全4行を読み込みます。id:4 はすでに deleted_at に日時が入っており、論理削除済みです。| comment_id | body | deleted_at |
|---|---|---|
| 1 | 素晴らしい記事です | NULL |
| 2 | 参考になりました | NULL |
| 3 | 不適切なコメント | NULL |
| 4 | ありがとうございます | 2024-05-01 |
LEGEND
① FROM
FROM comments — 論理削除後のテーブル論理削除後の comments テーブルです。id:3 の deleted_at に日時が入り、id:4 とともに「削除済み」状態になっています。行は残っていることに注目してください。| comment_id | post_id | body | deleted_at |
|---|---|---|---|
| 1 | 10 | 素晴らしい記事です | NULL |
| 2 | 10 | 参考になりました | NULL |
| 3 | 11 | 不適切なコメント | 2024-06-01 |
| 4 | 11 | ありがとうございます | 2024-05-01 |
WHERE deleted_at IS NULL が必要になる。ORMのデフォルトスコープやビュー(VIEW)で自動適用するのが実務上のベストプラクティス。DELETE FROM comments は全行削除。誤ってWHEREを省略したときの被害を最小限にするため、本番環境ではDELETEを実行する前に必ず SELECT COUNT(*) WHERE ... で件数確認をする。is_deleted = true/false では「いつ削除されたか」の情報が失われる。TIMESTAMPTZ型のdeleted_atにすれば「NULL=有効、日時=削除済み」の両方の情報が1列に収まり、削除日時による絞り込みも可能になる。email 列のユニーク制約(重複禁止)に引っかかるという有名な問題が発生します。実務ではこれを回避するため、「email と deleted_at の複合ユニーク制約」にするか、削除時に email の末尾に _deleted_タイムスタンプ を付与して退避させるなど、スキーマレベルでの運用工夫がセットで必要になります。
WITH(CTE) — 複雑なバッチクエリを分解して読みやすく・再利用しやすくする
CTE(Common Table Expression)は WITH 句で一時的な名前付き結果セットを定義し、後続のクエリで何度でも参照できる機能です。複雑なバッチクエリをステップごとに分解して読みやすくします。
WITH cte1 AS ( -- CTE名を定義(テーブルのように後で使える) SELECT col1, col2 FROM table_a WHERE ... ), cte2 AS ( -- 複数のCTEはカンマで繋ぐ(cte1を参照することも可) SELECT col1 FROM cte1 -- 前のCTEを参照できる WHERE ... ) SELECT * FROM cte2; -- 最後に本体クエリ(CTEを参照する)
「過去30日以内に2件以上注文したユーザー」をVIP顧客として取得したいです。orders(注文)と users(ユーザー)テーブルを使い、CTEを2段階に分けて、VIPユーザーの user_id, name, order_count を order_count 降順で返してください。現在日は 2024-06-01 とします。
| order_id | user_id | ordered_at |
|---|---|---|
| 1 | U01 | 2024-05-10 |
| 2 | U01 | 2024-05-20 |
| 3 | U02 | 2024-05-25 |
| 4 | U03 | 2024-04-01 |
| 5 | U01 | 2024-05-28 |
| 6 | U02 | 2024-05-29 |
| 7 | U03 | 2024-03-15 |
| user_id | name |
|---|---|
| U01 | 田中 太郎 |
| U02 | 佐藤 花子 |
| U03 | 鈴木 一郎 |
| user_id | name | order_count |
|---|---|---|
| U01 | 田中 太郎 | 3 |
| U02 | 佐藤 花子 | 2 |
WITH recent_orders AS ( -- CTE①: 過去30日の注文をユーザー別に集計 SELECT user_id, COUNT(*) AS order_count FROM orders WHERE ordered_at >= DATE '2024-06-01' - INTERVAL '30 days' -- 問題文の基準日から直近30日 GROUP BY user_id ), vip_users AS ( -- CTE②: 2件以上のユーザーに絞り込み SELECT user_id, order_count FROM recent_orders -- CTE① を参照 WHERE order_count >= 2 ) SELECT v.user_id, u.name, v.order_count FROM vip_users v INNER JOIN users u -- VIP に氏名を結合 ON v.user_id = u.user_id ORDER BY v.order_count DESC; /* 実行順序(SQLの論理的な評価順): 1. CTE① recent_orders → CTE を定義 2. CTE② vip_users → CTE を定義 3. FROM vip_users v → 行を読み込む 4. INNER JOIN users u → 結合(一致行のみ) 5. SELECT → 列を評価 6. ORDER BY order_count DESC → 並び替えて出力 */
LEGEND
① CTE① FROM+WHERE
recent_orders AS (FROM orders WHERE ordered_at >= 30日前)CTE① の内部処理です。orders テーブルから過去30日以内(2024-05-02以降)の注文だけを残します。U03 の注文(04-01, 03-15)はすべて30日超のため除外されます。| order_id | user_id | ordered_at | 30日以内? |
|---|---|---|---|
| 1 | U01 | 2024-05-10 | ✓ |
| 2 | U01 | 2024-05-20 | ✓ |
| 3 | U02 | 2024-05-25 | ✓ |
| 4 | U03 | 2024-04-01 | ✗ |
| 5 | U01 | 2024-05-28 | ✓ |
| 6 | U02 | 2024-05-29 | ✓ |
| 7 | U03 | 2024-03-15 | ✗ |
recent_orders(最近の注文)、vip_users(VIPユーザー)のように意味のある名前をつけることで、SQLを読んだ人が「何をしているか」を即座に理解できる。コメントの代わりになる。SELECT * FROM (SELECT * FROM (SELECT ...) sub1) sub2 のように3重・4重にネストするとどこで何をしているか全く読めなくなる。2段階以上になったらCTEへリファクタリングすること。SELECT * FROM orders でCTEを作ると後続で不要な列まで抱えることになる。CTEでも必要な列だけ明示的に SELECT すること。パフォーマンスと可読性の両方に影響する。EXISTS / NOT EXISTS — 関連データの存在チェックを効率的に行う
EXISTS はサブクエリが1行以上の結果を返すかどうかを TRUE/FALSE で判定します。「関連データが存在する行だけ取得」「まだ処理されていない行を取得」のバッチ処理で頻出です。
SELECT col1 FROM table_a a WHERE EXISTS ( -- サブクエリが1件以上あれば TRUE SELECT 1 -- EXISTS内はSELECT 1でよい(列は何でもよい) FROM table_b b WHERE b.a_id = a.id -- 外側のテーブルを参照(相関サブクエリ) ); WHERE NOT EXISTS (...) -- NOT EXISTS: サブクエリが0件のとき TRUE
WHERE id IN (SELECT id FROM ...) でも似たことができるが、EXISTSはサブクエリが1行見つかった時点でスキャンを中断するため大量データで高速。また IN はサブクエリにNULLが含まれると予期しない動作をする場合がある。users テーブルから、まだ一度も注文したことがないユーザー(orders テーブルに対応レコードなし)を取得してください。user_id と name を返し、user_id の昇順で並べてください。
| user_id | name |
|---|---|
| 1 | 田中 太郎 |
| 2 | 佐藤 花子 |
| 3 | 鈴木 一郎 |
| 4 | 山田 次郎 |
| 5 | 伊藤 三郎 |
| order_id | user_id | amount |
|---|---|---|
| 101 | 1 | 5000 |
| 102 | 3 | 3200 |
| 103 | 1 | 8800 |
| user_id | name |
|---|---|
| 2 | 佐藤 花子 |
| 4 | 山田 次郎 |
| 5 | 伊藤 三郎 |
SELECT u.user_id, u.name FROM users u WHERE NOT EXISTS ( -- 注文が1件も無いユーザーだけ SELECT 1 -- 存在判定なので値は何でもよい FROM orders o WHERE o.user_id = u.user_id -- 外側と相関(注文の有無を判定) ) ORDER BY u.user_id ASC; /* 実行順序(SQLの論理的な評価順): 1. FROM users u → 行を読み込む 2. NOT EXISTS (サブクエリ) → サブクエリを先に評価 3. SELECT → 列を評価 4. ORDER BY u.user_id → 並び替えて出力 */
LEGEND
① FROM
FROM users u外側クエリの対象テーブル users 全5行を読み込みます。次のステップで各行に対して NOT EXISTS の相関サブクエリが実行されます。| user_id | name |
|---|---|
| 1 | 田中 太郎 |
| 2 | 佐藤 花子 |
| 3 | 鈴木 一郎 |
| 4 | 山田 次郎 |
| 5 | 伊藤 三郎 |
NOT EXISTS (SELECT 1 FROM email_logs WHERE user_id = u.user_id)、「未課金ユーザーへのリマインダー送信」など、処理済み/未処理を管理するバッチに頻出。SELECT 1(定数)で書くのが慣例。SELECT * でも動くが意図が伝わりにくい。WHERE user_id NOT IN (SELECT user_id FROM orders) は orders.user_id にNULLが1件でも含まれると全件 FALSE になり0件が返る。NOT IN はNULLに弱いため NOT EXISTS を使う方が安全。WHERE EXISTS (SELECT 1 FROM orders)(WHERE o.user_id = u.user_id なし)はordersに1行でもあれば全ユーザーがTRUEになる。外側テーブルとの結合条件は必須。NOT EXISTS の他に LEFT JOIN + IS NULL を使う方法があります。実行計画(パフォーマンス)はデータベースエンジンによって同等に最適化されることが多いですが、NOT EXISTS の方が「存在チェックをしている」という意図がSQLから明確に読み取れるため、チーム開発での可読性・メンテナンス性の観点から実務で好んで採用されます。
CASE WHEN — バッチ集計・APIレスポンスで値を条件分岐して変換する
CASE WHEN はSQLのif-else構文です。SELECT・WHERE・ORDER BY・GROUP BY など様々な場所で使えます。APIレスポンスでDBの値をラベル変換したり、集計で条件付きカウントを行うバッチ処理に頻出です。
-- 単純CASE(列の値を列挙して分岐) CASE col1 WHEN 'val1' THEN '表示A' -- col1 = 'val1' のとき WHEN 'val2' THEN '表示B' -- col1 = 'val2' のとき ELSE 'その他' -- どれにも一致しない場合(省略すると NULL) END -- CASE は必ず END で閉じる -- 検索CASE(任意の条件式で分岐・より汎用的) CASE WHEN col2 > 100 THEN '大' -- 比較演算子も使える WHEN col2 > 50 THEN '中' -- 上から順に評価し最初に一致した THEN を返す ELSE '小' END
orders テーブルから、各注文の amount を基に購買ランク(10000以上:premium / 5000以上:standard / それ未満:basic)をつけ、さらに status を日本語ラベルに変換して返してください。order_id, amount, rank_label, status_label を ordered_at の降順で返してください。
| order_id | amount | status | ordered_at |
|---|---|---|---|
| 1 | 12000 | completed | 2024-05-20 |
| 2 | 4800 | pending | 2024-05-18 |
| 3 | 7500 | cancelled | 2024-05-15 |
| 4 | 500 | completed | 2024-05-10 |
| 5 | 5000 | pending | 2024-05-08 |
| order_id | amount | rank_label | status_label |
|---|---|---|---|
| 1 | 12000 | premium | 完了 |
| 2 | 4800 | basic | 処理中 |
| 3 | 7500 | standard | キャンセル |
| 4 | 500 | basic | 完了 |
| 5 | 5000 | standard | 処理中 |
SELECT order_id, amount, CASE -- 金額で購買ランクを分岐 WHEN amount >= 10000 THEN 'premium' WHEN amount >= 5000 THEN 'standard' -- 上から順に評価(最初の一致を採用) ELSE 'basic' END AS rank_label, CASE status -- 列の値で分岐(単純 CASE) WHEN 'completed' THEN '完了' WHEN 'pending' THEN '処理中' WHEN 'cancelled' THEN 'キャンセル' ELSE '不明' -- 想定外の値への備え END AS status_label FROM orders ORDER BY ordered_at DESC; /* 実行順序(SQLの論理的な評価順): 1. FROM orders → 行を読み込む 2. SELECT → 列を評価(rank_label, status_label) 3. ORDER BY ordered_at DESC → 並び替えて出力 */
LEGEND
① FROM
FROM ordersorders テーブル全5行を読み込みます。CASE WHEN は SELECT 句の中で各行に対して評価されます。| order_id | amount | status | ordered_at |
|---|---|---|---|
| 1 | 12000 | completed | 2024-05-20 |
| 2 | 4800 | pending | 2024-05-18 |
| 3 | 7500 | cancelled | 2024-05-15 |
| 4 | 500 | completed | 2024-05-10 |
| 5 | 5000 | pending | 2024-05-08 |
SUM(CASE WHEN status='completed' THEN amount ELSE 0 END) のように集計関数と組み合わせると「特定条件の合計金額だけ集計」ができる。GROUP BY と組み合わせてKPI集計に頻用される。ELSE '不明' などを書く習慣が重要。WHEN amount >= 5000 THEN 'standard' WHEN amount >= 10000 THEN 'premium' と順番を逆にすると、12000は最初のWHEN(>=5000)で 'standard' に確定してしまい 'premium' に到達しない。上から順に評価される点を必ず意識すること。