暗黙の型変換 — 型が違う比較は index を静かに殺す(か、突然落ちる)
WHERE 句で列とリテラル(またはパラメータ)の型が一致しないとき、何が起きるかは DBMS 任せです。PostgreSQL は varchar = integer の比較をエラーにし、MySQL や Oracle は列側を暗黙キャストして比較します。列がキャストされた瞬間、それは「列を関数で包んだ」のと同じ——index は候補から外れます(Sargable 違反)。コードに書いていないキャストが挿入されるぶん、関数包みより発見が難しい罠です。
-- ✗ varchar 列 × 数値リテラル → PostgreSQL は即エラー / MySQL等は列を暗黙キャスト WHERE member_code = 250 -- ✗ エラー回避で「列側」をキャスト → 全行変換 + 数値化できない行で実行時エラー WHERE member_code::int = 250 -- ✓ 「リテラル側」を列の型に揃える → varchar 同士の素直な比較で index が効く WHERE member_code = '250'
members テーブル(実体100万行、member_code に indexあり)から、会員コードが 250 の会員を取得してください。member_code は旧システム由来の varchar 型で、ほとんどは数字ですが 'A-100' のような旧形式コードも実在します。index が使える形・実行時エラーの危険がない形で書くこと。出力列は member_id, member_code, name。
| member_id | member_code | name |
|---|---|---|
| 1 | 100 | Sato |
| 2 | 250 | Suzuki |
| 3 | A-100 | Tanaka |
| 4 | 30 | Ito |
| 5 | 1200 | Watanabe |
| member_id | member_code | name |
|---|---|---|
| 2 | 250 | Suzuki |
SELECT member_id, member_code, name FROM members WHERE member_code = '250' -- リテラル側を列の型(varchar)に揃える → index の等価検索 ORDER BY member_id; -- ✗ member_code = 250 … PostgreSQL では比較演算子が無くエラー -- ✗ member_code::int = 250 … 全行キャスト(index不可)+ 'A-100' で実行時エラー /* 実行順序: 1. FROM members → 対象にする 2. WHERE member_code = '250' → キャストなしで idx 等価検索 3. SELECT → 該当行のみ読む 4. ORDER BY member_id → 実質コストなし */
LEGEND
1. 対象テーブル — member_code は varchar(旧形式コードが混在)
FROM members(member_code に index あり)member_code は旧システム由来の文字列型(varchar)。中身はほぼ数字ですが、'A-100' のような旧形式も実在します。「数字に見える文字列」は数値リテラルで検索したくなる罠の入口です。| member_id | member_code(varchar) | name |
|---|---|---|
| 1 | '100' | Sato |
| 2 | '250' | Suzuki |
| 3 | 'A-100' | Tanaka |
| 4 | '30' | Ito |
| 5 | '1200' | Watanabe |
DATE_TRUNC(ordered_at) 等)はコードを見れば分かりますが、暗黙キャストはDBMSが勝手に挿入するためコード上は無実に見えます。発見の手がかりは EXPLAIN の出力——条件式に (member_code)::integer のような書いた覚えのないキャストが現れていたら、型不一致を疑ってください。::int キャストのエラーは「数値化できない行を実際に読んだ瞬間」に起きます。評価順や走査順はプランナの自由なので、テスト環境('A-100' なし)では動き、本番('A-100' あり)で落ちる——動作確認をすり抜けるタイプの事故です。型の正しさはデータの偶然に頼らず、書き方で保証します。::int で落ちたら今度は CASE WHEN member_code ~ '^[0-9]+$' THEN member_code::int END = 250 ……と守りのコードを積むほど、クエリは重く・読めなくなります。数値として扱う値ならスキーマで数値型に直すのが根本治療。直せない事情があるなら「文字列として比較する」側に倒すのが次善です。a.user_id(int) と b.user_code(varchar) を ON a.user_id::text = b.user_code で繋ぐ JOIN は、結合のたびに片側全行のキャストを払い続けます。システム統合・データ移行の境界面で最も起きやすく、最も気づかれにくい劣化です。マスタを跨ぐキーは型まで含めて統一するのが設計の仕事です。'250' と '0250' は別の値ですが、数値の 250 と 0250 は同じです。さらに文字列の ORDER BY では '1200' < '30'(辞書順)になり、数値の直感と食い違います。つまり型の選択は index の効き目だけでなく、等しさと順序の定義そのものを決めています。「コードや電話番号のように計算しない数字は文字列、計算する数字は数値型」が定石ですが、どちらにせよ比較の両辺で型を揃える原則は変わりません。関連する実務テーマとして、「各グループの最新1件」——相関サブクエリ(Q1)に代わる、ウィンドウ関数という新しい道具を学びます。ウィンドウ関数 ROW_NUMBER — 「グループごとの最新1件」を1回の走査で取る
「ユーザーごとの最新ログイン」のようなグループ内の最新1件(greatest-n-per-group)は実務最頻出の要件です。相関サブクエリで logged_at = (SELECT MAX(...)) と書くと、N+1 問題に加えて同時刻タイで2行返る正しさの問題まで抱えます。ウィンドウ関数 ROW_NUMBER() なら、PARTITION BY でグループを区切り、グループ内の並び順で各行に番号を付与——全体を1回の走査(1パス)で処理できます。
-- ✗ 行ごとに MAX を探索(N+1)+ 同時刻タイが2行とも返る WHERE logged_at = (SELECT MAX(logged_at) FROM logins x WHERE x.user_id = l.user_id) -- ✓ 区画ごとに番号付け → 外側で rn = 1 だけ残す(必ず1件) ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY logged_at DESC, login_id DESC) AS rn
logins テーブルから、ユーザーごとの最新ログイン1件(日時・デバイス)を取得してください。同時刻のログインが複数ある場合は login_id が大きい方を採用し、必ずユーザー1人につき1行になることを保証すること。テーブルの走査は1回で済む形で書くこと。出力列は user_id, logged_at, device(user_id 昇順)。
| login_id | user_id | logged_at | device |
|---|---|---|---|
| 1 | 1 | 2026-06-01 09:00 | mobile |
| 2 | 2 | 2026-06-01 10:00 | pc |
| 3 | 1 | 2026-06-03 12:00 | pc |
| 4 | 3 | 2026-06-02 08:00 | pc |
| 5 | 2 | 2026-06-04 18:00 | mobile |
| 6 | 3 | 2026-06-02 08:00 | tablet |
| 7 | 1 | 2026-06-02 20:00 | tablet |
| user_id | logged_at | device |
|---|---|---|
| 1 | 2026-06-03 12:00 | pc |
| 2 | 2026-06-04 18:00 | mobile |
| 3 | 2026-06-02 08:00 | tablet |
SELECT user_id, logged_at, device FROM ( SELECT user_id, logged_at, device, ROW_NUMBER() OVER (PARTITION BY user_id -- ユーザーごとに区画 ORDER BY logged_at DESC, login_id DESC) AS rn -- タイブレーク必須 FROM logins ) t WHERE rn = 1 -- ウィンドウ関数は WHERE に直接書けないため外側で絞る ORDER BY user_id; -- 別解(PostgreSQL専用・簡潔): -- SELECT DISTINCT ON (user_id) user_id, logged_at, device -- FROM logins ORDER BY user_id, logged_at DESC, login_id DESC; /* 実行順序: 1. 内側 FROM logins → 1回走査 2. ウィンドウ → user_id で区画化し ROW_NUMBER を付与 3. 外側 WHERE rn = 1 → 区画ごとに1行 4. SELECT → 3列を確定 5. ORDER BY user_id → 昇順に整列 */
LEGEND
1. 対象テーブル — ユーザー3人分のログイン履歴
FROM logins欲しいのは「ユーザー1人につき最新の1行」。user_id=3 は 06-02 08:00 のログインが2件(pc / tablet)ある点に注目——この同時刻タイが、書き方の正しさを試します。| login_id | user_id | logged_at | device |
|---|---|---|---|
| 1 | 1 | 2026-06-01 09:00 | mobile |
| 2 | 2 | 2026-06-01 10:00 | pc |
| 3 | 1 | 2026-06-03 12:00 | pc |
| 4 | 3 | 2026-06-02 08:00 | pc |
| 5 | 2 | 2026-06-04 18:00 | mobile |
| 6 | 3 | 2026-06-02 08:00 | tablet |
| 7 | 1 | 2026-06-02 20:00 | tablet |
ORDER BY logged_at DESC だけだと同時刻の rn は実行ごとに変わり得ます(非決定的)。一意キー(login_id)を最後に足せば順位は常に一意——「再実行したら結果が変わった」を構造的に封じます。MAX 相関サブクエリ版がタイで2行返すのと対照的に、ROW_NUMBER は「必ず1件」を書き方のレベルで保証できるのが本質的な強みです。WHERE rn = 1 を同じ階層には書けません。サブクエリ/CTEで一段囲むのは回り道ではなく評価順序の必然です。PostgreSQL なら DISTINCT ON、対応する処理系なら QUALIFY 句という短縮形もあります。GROUP BY user_id に MAX(logged_at), MAX(device) を並べると、日時はある行から・device は別の行から……という実在しないフランケン行ができます。エラーにならず一見もっともらしい値が返るぶん、タチの悪い正しさバグです。「同じ行から取れている保証があるか」を常に問うてください。DISTINCT ON が簡潔、グループ数が少なく1グループあたりの行数が膨大なら LATERAL + LIMIT 1 で index を区画ごとに刺す手もあります。さらに index を (user_id, logged_at DESC, login_id DESC) と並びまで揃えれば、既習の「ソート工程を消す」がウィンドウ処理にも効きます。関連する実務テーマとして、一覧画面のもう1つの定番——ページングの罠です。OFFSET の罠とキーセット — 深いページは「読んで捨てる」をやめてシークする
ページングの定番 LIMIT 20 OFFSET 100000 は、内部では100,020行を読んで100,000行を捨てています。OFFSET のコストは捨てる行数に比例するため、後ろのページほど遅くなり、途中で行が挿入されるとページずれ(重複・欠落)も起きます。キーセットページネーション(シーク法)は「前ページ最後の行のキー」を覚えておき、その続きから index を読む方式——どのページでもコストは一定です。
-- ✗ 3ページ目: 6行読んで4行捨てる(10万ページ目なら…) ORDER BY published_at DESC, article_id DESC LIMIT 2 OFFSET 4 -- ✓ キーセット: 前ページ最終行のキーの「続き」だけ読む WHERE (published_at, article_id) < (TIMESTAMP '2026-05-05 13:00', 5) ORDER BY published_at DESC, article_id DESC LIMIT 2
(a, b) < (x, y) は辞書式で「a < x OR (a = x AND b < y)」の意味。ORDER BY と同じ列・同じ向きでカーソル条件を書くのが鉄則で、一意キーを含めることで同時刻タイでもページが安定します。articles テーブル(実体100万行、(published_at, article_id) に複合 index あり)の記事一覧を、新着順(published_at DESC, article_id DESC)に2件/ページで表示します。2ページ目の最終行が (published_at, article_id) = ('2026-05-05 13:00', 5) でした。OFFSET を使わず、3ページ目の2件を取得してください。出力列は article_id, title, published_at。
| article_id | title | published_at |
|---|---|---|
| 1 | 春の特集 | 2026-05-01 09:00 |
| 2 | 新機能紹介 | 2026-05-02 10:00 |
| 3 | 障害報告 | 2026-05-03 11:00 |
| 4 | 対談記事 | 2026-05-04 12:00 |
| 5 | 採用情報 | 2026-05-05 13:00 |
| 6 | 技術ブログ | 2026-05-05 13:00 |
| 7 | 夏の特集 | 2026-05-06 14:00 |
| 8 | お知らせ | 2026-05-07 15:00 |
| article_id | title | published_at |
|---|---|---|
| 4 | 対談記事 | 2026-05-04 12:00 |
| 3 | 障害報告 | 2026-05-03 11:00 |
SELECT article_id, title, published_at FROM articles WHERE (published_at, article_id) < (TIMESTAMP '2026-05-05 13:00', 5) -- 前ページ最終行のキー ORDER BY published_at DESC, article_id DESC -- カーソルと同じ列・同じ向き LIMIT 2; -- 前提 index: CREATE INDEX ON articles (published_at, article_id);(逆順走査で DESC に対応) /* 実行順序: 1. FROM articles → 対象にする 2. WHERE 行値比較 → index 上のシークに変換 3. ORDER BY → index の並びを利用しソートなし 4. LIMIT → 2件で打ち切り */
LEGEND
1. 対象テーブル — 新着順の一覧を2件ずつページング
FROM articles(新着順 = published_at DESC, article_id DESC)新着順の並びは 8→7→6→5→4→3→2→1。id=5 と 6 は同時刻で、並びは article_id DESC が決めます。1ページ目=8,7 / 2ページ目=6,5 まで表示済み、という状況です。| 並び | article_id | title | published_at |
|---|---|---|---|
| 1 | 8 | お知らせ | 2026-05-07 15:00 |
| 2 | 7 | 夏の特集 | 2026-05-06 14:00 |
| 3 | 6 | 技術ブログ | 2026-05-05 13:00 |
| 4 | 5 | 採用情報 | 2026-05-05 13:00 |
| 5 | 4 | 対談記事 | 2026-05-04 12:00 |
| 6 | 3 | 障害報告 | 2026-05-03 11:00 |
| 7 | 2 | 新機能紹介 | 2026-05-02 10:00 |
| 8 | 1 | 春の特集 | 2026-05-01 09:00 |
LIMIT 20 OFFSET 100000 は100,020行を読み、100,000行を捨てます。1ページ目は速く、後ろへ行くほど線形に遅くなる——「最初は問題なかったのにデータが増えたら一覧が重い」の典型犯です。さらに表示中の挿入・削除で行がずれ、重複表示や取りこぼしも発生します。性能と正しさの両面で、深いページの OFFSET は構造的に不利です。(a, b) < (x, y) は a < x OR (a = x AND b < y) と同義の辞書式比較です。手書きの OR 展開でも動きますが、行値構文の方が意図が明瞭で、プランナも複合 index の範囲条件として扱いやすくなります。本問の id=6(同時刻タイ)が2列目の比較で正しく除外される動きは、一意キーをカーソルに含める理由そのものです。ORDER BY published_at DESC なのにカーソルが article_id < ? だけ、あるいはタイブレーク列をカーソルに含めない——この不一致は同時刻データで行の重複・欠落を起こします。「並び順キーの全列を、同じ向きで、タプルごと比較する」を機械的なチェックリストにしてください。next_cursor として返し、クライアントは次のリクエストにそれを添える——DB の行値比較に直結する、薄くて速い設計です。注意点はカーソルの中身に並び順の全キーを入れること、並び替え条件が変わったらカーソルを無効化すること。関連する実務テーマとして、ページングよりさらに小さな質問——「あるか/ないか」だけを聞きたいときの最短経路を扱います。EXISTS と COUNT — 「あるか/ないか」は数えずに最初の1件で打ち切る
「このユーザーに注文があるか?」を判定したいとき、COUNT(*) > 0 で書くと全件を数えてしまいます。本当に欲しいのは「ある/ない」のブール値であって件数ではありません。EXISTS は内側のクエリが1行でも返した瞬間に処理を打ち切る——内部表現は Semi Join で、DISTINCT なしでファンアウトを避けるときと同じ道具です。「件数で語るか、存在で語るか」の差が、走査量を百倍・千倍変えます。
-- ✗ 件数を数え切らないと判定できない(最悪は全件読む) SELECT CASE WHEN (SELECT COUNT(*) FROM orders WHERE user_id = 42) > 0 THEN 'yes' ELSE 'no' END; -- ✓ 1件見つけた瞬間に打ち切り(Semi Join) SELECT EXISTS (SELECT 1 FROM orders WHERE user_id = 42);
users(実体10万行)と orders(実体500万行・user_id に index あり)から、「注文が1件でもあるユーザー」と「未注文ユーザー」を抽出して、それぞれの存在フラグ付き一覧を作ってください。判定のためにユーザーごとに注文件数を数えてはいけません(件数は答えに不要・走査が無駄)。出力列は user_id, name, has_order('yes' / 'no'、user_id 昇順)。
| user_id | name |
|---|---|
| 1 | Sato |
| 2 | Suzuki |
| 3 | Tanaka |
| 4 | Ito |
| order_id | user_id |
|---|---|
| 101 | 1 |
| 102 | 1 |
| 103 | 1 |
| 104 | 2 |
| 105 | 3 |
| 106 | 3 |
| user_id | name | has_order |
|---|---|---|
| 1 | Sato | yes |
| 2 | Suzuki | yes |
| 3 | Tanaka | yes |
| 4 | Ito | no |
SELECT u.user_id, u.name, CASE WHEN EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.user_id -- 1件見つけたら即打ち切り ) THEN 'yes' ELSE 'no' END AS has_order FROM users u ORDER BY u.user_id; -- ✗: CASE WHEN (SELECT COUNT(*) FROM orders WHERE user_id = u.user_id) > 0 … -- → ユーザーごとに注文を全件カウント(idx(user_id) の Range Scan を最後まで走り切る) /* 実行順序: 1. FROM users → 走査 2. SELECT 内 EXISTS → idx で1件見つけたら打ち切り(Semi Join) 3. CASE → TRUE/FALSE を 'yes'/'no' に変換 4. ORDER BY user_id → 昇順 */
LEGEND
1. 対象テーブル — ユーザーごとに「注文の有無」を判定したい
FROM users u(各ユーザーについて orders を問い合わせる)欲しいのは「ある/ない」のブール値、つまり1件でも見つかれば答えは確定。Sato の注文が100万件あろうと、最初の1件が見つかれば残りを読む理由はありません。| user_id | name | orders の件数(参考) |
|---|---|---|
| 1 | Sato | 多数(最悪100万件) |
| 2 | Suzuki | 1件 |
| 3 | Tanaka | 多数 |
| 4 | Ito | 0件 |
SELECT 1 / SELECT * / SELECT NULL いずれもパフォーマンスは同じ。慣習的に SELECT 1 を書くのは「列名や値は何でもよく、存在だけを聞いている」という読み手への明示のためです。中で集計や ORDER BY を書く必要も一切ありません。EXISTS(Semi Join)、「ない」を聞くなら NOT EXISTS(Anti Join・既習)。どちらも index がある限り「1件見つかった/見つからなかった」で打ち切れるのが本質。IN / NOT IN は意味的に近いものの、NOT IN は NULL の罠があり、また EXISTS ほど打ち切りの意図が明瞭ではありません。存在判定は EXISTS 系に寄せるのが定石です。if (count > 0) としているのに、SQL は COUNT を返す——これが現場で最頻出のアンチパターンです。件数を見ているのは判定だけ、表示には使っていないなら、その時点で EXISTS に置き換えられます。「数えた件数を本当に出力に使っているか?」を自問してください。EXISTS (SELECT 1 FROM orders ORDER BY ordered_at DESC LIMIT 1) のような書き方を見かけますが、EXISTS の中の並びや件数は結果に影響しません(存在だけが意味を持つ)。冗長なだけでなく「最新の注文があるか」のような誤った意図を読み手に与える害もあります。EXISTS の中は WHERE 条件だけにしてください。総合問題 — ウィンドウ + キーセット + 部分 index + 型を1つの管理画面SQLに
本編の総まとめとして、実務頻出の管理画面 SQL を組み立てます。「未処理タスクのうち、各担当者の最新1件だけを、新着順にカーソルで切り出す」——5つの基礎技がきれいに役割分担します。
① 部分インデックスで 'pending' の少数派だけを索引化、② ROW_NUMBER で各担当者の最新1件に絞り、③ キーセットで OFFSET なしのページング、④ 暗黙の型変換を避けて index を生かし、⑤ EXISTS でフィルタを「存在」で表現。基礎編3で学んだ「意図を伝える / 関数で列を包まない / 集合で1回 / Top-N を打ち切る」がすべて1つのクエリに結晶します。
-- 全体像(疑似コード) WITH ranked AS ( -- ① ROW_NUMBER で担当者ごとの最新1件 SELECT ..., ROW_NUMBER() OVER (PARTITION BY assignee_id ORDER BY created_at DESC, task_id DESC) AS rn FROM tasks WHERE status = 'pending' -- ② 部分 index が効く形(型は string で揃える) ) SELECT ... FROM ranked WHERE rn = 1 AND (created_at, task_id) < (?, ?) -- ③ キーセットで次ページへ AND EXISTS (SELECT 1 FROM task_tags ...) -- ⑤ EXISTS で存在判定 ORDER BY created_at DESC, task_id DESC LIMIT ?;
タスク管理 SaaS の「担当者ごとの最新の未処理タスク一覧(新着順)」画面を作ります。要件は以下です:
- 担当者1人につき最新の pending タスクを1件だけ表示(タイブレーク: task_id DESC)
- 「重要」タグが付いたタスクだけを抽出(
task_tagsにtag = 'important'がある) - 新着順(
created_at DESC, task_id DESC)に 2件/ページ でカーソル方式ページング - 前ページ最終行のカーソル:
(created_at, task_id) = ('2026-06-04 11:00', 4)(カーソル位置からの次ページを取得) - OFFSET 不使用、テーブルの全行走査を避けるため 部分 index も合わせて提示すること(status は varchar)
| task_id | assignee_id | title | status | created_at |
|---|---|---|---|---|
| 1 | 1 | 請求書確認 | pending | 2026-06-08 10:00 |
| 2 | 1 | 議事録整理 | pending | 2026-06-05 09:00 |
| 3 | 2 | 採用面談 | pending | 2026-06-07 14:00 |
| 4 | 2 | 設計レビュー | pending | 2026-06-04 11:00 |
| 5 | 3 | 監査対応 | pending | 2026-06-03 16:00 |
| 6 | 3 | 契約書修正 | pending | 2026-06-02 09:00 |
| 7 | 4 | 備品発注 | pending | 2026-05-30 13:00 |
| 8 | 5 | 清掃手配 | done | 2026-06-09 09:00 |
| task_id | tag |
|---|---|
| 1 | important |
| 2 | internal |
| 3 | important |
| 4 | important |
| 5 | important |
| 6 | internal |
| 7 | important |
| task_id | assignee_id | title | created_at |
|---|---|---|---|
| 5 | 3 | 監査対応 | 2026-06-03 16:00 |
| 7 | 4 | 備品発注 | 2026-05-30 13:00 |
CREATE INDEX idx_tasks_pending_keyset -- 部分 index: 'pending' 行だけを担当者ごとの最新順に索引化(Q5 + Q7) ON tasks (assignee_id, created_at DESC, task_id DESC) WHERE status = 'pending'; CREATE INDEX idx_task_tags_task_tag ON task_tags (task_id, tag); -- 補助 index: 重要タグの存在確認用(EXISTS が刺さる) WITH ranked AS ( SELECT task_id, assignee_id, title, created_at, ROW_NUMBER() OVER (PARTITION BY assignee_id -- ① 担当者ごとに区画 ORDER BY created_at DESC, task_id DESC) AS rn -- タイブレーク必須 FROM tasks WHERE status = 'pending' -- ② 部分 index が含意で選ばれる(文字列同士・Q6) ) SELECT task_id, assignee_id, title, created_at FROM ranked WHERE rn = 1 -- 各担当者の最新1件だけ残す AND (created_at, task_id) < (TIMESTAMP '2026-06-04 11:00', 4) -- ③ キーセット (Q8) AND EXISTS ( -- ⑤ 存在判定 (Q9) SELECT 1 FROM task_tags tt WHERE tt.task_id = ranked.task_id AND tt.tag = 'important' ) ORDER BY created_at DESC, task_id DESC -- カーソルと同じ列・同じ向き LIMIT 2; /* 実行順序: 1. CTE 内 FROM/WHERE → status='pending' で部分 index を採用 2. CTE 内 ウィンドウ → 区画化し ROW_NUMBER を付与(担当者ごとの index 順を利用) 3. 外側 WHERE rn = 1 → 担当者ごとの先頭行 4. キーセット行値比較 → 古い側へジャンプ 5. EXISTS → important の有無を1件探索(Semi Join) 6. ORDER BY created_at DESC, task_id DESC → 残った候補を並べて2件で終了 */
LEGEND
1. 元データ — tasks 100万行 + task_tags(多対多)
FROM tasks(status='pending' は約0.5%・偏った分布)100万行のうち pending は約0.5%。done を含めて全行を読み始めた瞬間に負けが確定する規模感です。task_tags は多対多の中間テーブル(既習)で、important フラグを別テーブルで管理する典型構造。| task_id | assignee_id | title | status | created_at |
|---|---|---|---|---|
| 1 | 1 | 請求書確認 | pending | 2026-06-08 10:00 |
| 2 | 1 | 議事録整理 | pending | 2026-06-05 09:00 |
| 3 | 2 | 採用面談 | pending | 2026-06-07 14:00 |
| 4 | 2 | 設計レビュー | pending | 2026-06-04 11:00 |
| 5 | 3 | 監査対応 | pending | 2026-06-03 16:00 |
| 6 | 3 | 契約書修正 | pending | 2026-06-02 09:00 |
| 7 | 4 | 備品発注 | pending | 2026-05-30 13:00 |
| 8 | 5 | 清掃手配 | done | 2026-06-09 09:00 |
(assignee_id, created_at DESC, task_id DESC) という並びは、担当者ごとのウィンドウの PARTITION BY + ORDER BY を一発で表現します。外側の全体順序(created_at DESC, task_id DESC)は担当者キーのない別の並びなので、最新1件に絞った候補へ最後に適用します。「ウィンドウ用とソート用と検索用、index を3本貼る」のは初学者の選択と決めつけず、要件ごとの並びのトレードオフを説明して index を設計する方向へ視点を移します。index は「列のセット」ではなく「列の順序付きシーケンス」だと意識すると、この設計が自然になります。UPPER(status) = 'PENDING'」——個別の論点を別々に解いて積むと、関数包み + ファンアウト + 全走査 + 行捏造が一発で揃います。クエリは部品を組み合わせるのではなく、「データの流れ」として1本の経路を設計するのが正解です。背骨に流れているのは、「実行回数 × 1回のコスト」を両方減らすこと、「列を関数や暗黙キャストで包まないこと」、「中間結果のサイズを小さく保つこと」、そして「意図をプランナに正確に伝えること」です。基礎編全体の地図が手に入った今、次に必要なのはEXPLAIN の実出力を読む技術と、結合アルゴリズム・統計情報・並列実行といったプランナの内部です。これらは続編で扱います。お疲れさまでした。