IN述語(サブクエリ)と NOT IN の NULL の罠 — 動的リストと三値論理の危険
IN の内側にサブクエリを書くと、テーブルの状態に応じて動的にリストを生成できます。また NOT IN のリストに NULL が1件でも含まれると全件0件になる「NULL の罠」が存在します。
-- IN サブクエリ:planがpremiumのuser_idを動的に生成 WHERE user_id IN ( SELECT user_id FROM users WHERE plan = 'premium' ) -- NOT IN の罠:user_id に NULL が1件でも含まれると全件0件になる WHERE user_id NOT IN ( SELECT user_id FROM orders WHERE user_id IS NOT NULL -- NULL混入防止が必須 )
5 NOT IN (1, NULL, 3) は、各値との不一致を AND でつないだ判定と同じです。このうち 5<>NULL は真偽を確定できず UNKNOWN になります。AND 全体も UNKNOWN となり、WHERE は TRUE の行だけを残すため、この行は除外されます。リストに NULL が1件でも混入すると、どの値も同じ理由で通過できません。orders テーブルから、plan が 'premium' のユーザーの注文を IN サブクエリで取得してください。取得列は order_id, user_id, amount, status、amount 降順で返してください。
| user_id | name | plan |
|---|---|---|
| 1 | 田中 太郎 | premium |
| 2 | 佐藤 花子 | free |
| 3 | 鈴木 一郎 | premium |
| 4 | 山田 次郎 | standard |
| order_id | user_id | amount | status |
|---|---|---|---|
| 101 | 1 | 8000 | completed |
| 102 | 1 | 12000 | completed |
| 103 | 2 | 3500 | pending |
| 104 | 3 | 9500 | completed |
| 105 | 4 | 6000 | cancelled |
| order_id | user_id | amount | status |
|---|---|---|---|
| 102 | 1 | 12000 | completed |
| 104 | 3 | 9500 | completed |
| 101 | 1 | 8000 | completed |
SELECT order_id, user_id, amount, status FROM orders WHERE user_id IN ( -- IN: サブクエリ結果リストに一致する行を残す SELECT user_id -- 必ず単一列を返す(複数列はエラー) FROM users WHERE plan = 'premium' -- premium の user_id リストを動的生成 ) ORDER BY amount DESC; /* 実行順序(SQLの論理的な評価順): 1. サブクエリ先行評価 → SELECT user_id FROM users WHERE plan='premium' → 結果リスト: [1, 3] 2. FROM orders → 5行読み込み 3. WHERE user_id IN (1, 3) → 3行に絞り込み 4. SELECT order_id, user_id, ... → 4列を選択 5. ORDER BY amount DESC → 金額降順 */
LEGEND
① メインクエリ FROM orders
FROM orders外側のメインクエリで取得対象となる orders テーブル全5行です。| order_id | user_id | amount | status |
|---|---|---|---|
| 101 | 1 | 8000 | completed |
| 102 | 1 | 12000 | completed |
| 103 | 2 | 3500 | pending |
| 104 | 3 | 9500 | completed |
| 105 | 4 | 6000 | cancelled |
IN (1, 3) と論理的に等価ですが、テーブルの状態に応じて動的に変わる点が重要です。col <> NULL が UNKNOWN になり全行が除外されます。NOT IN の内側には必ず WHERE col IS NOT NULL を追加するか、NOT EXISTS を使いましょう(Q8で詳解)。WHERE user_id IN (SELECT user_id FROM users WHERE plan='premium') は INNER JOIN users ON orders.user_id = users.user_id WHERE users.plan='premium' と同じ結果です。結合先の列もSELECTに出す必要がない場合は IN サブクエリのほうが意図が明確です。SELECT user_id, name FROM users のように複数列を返すと構文エラーになります。IN の比較対象と一致する単一列だけをSELECTしましょう。WHERE col IS NOT NULL を必ず追加するか、NOT EXISTS に変更してください。EXISTS述語(基本)— 「行が存在するか」だけを確認する最も効率的なパターン
WHERE EXISTS (サブクエリ) は、サブクエリが「1行でも結果を返す場合」にその行を残します。何を返すかは関係なく「行が存在するか」だけを確認するため、内側は SELECT 1 で十分です。
SELECT * FROM users u WHERE EXISTS ( SELECT 1 -- 値は何でもよい。行の存在だけを確認 FROM orders o WHERE o.user_id = u.user_id -- 外側列を参照する「相関サブクエリ」 );
users テーブルから、status が 'completed' の注文が1件以上あるユーザーを取得してください。取得列は user_id, name, plan、user_id 昇順で返してください。
| user_id | name | plan |
|---|---|---|
| 1 | 田中 太郎 | premium |
| 2 | 佐藤 花子 | free |
| 3 | 鈴木 一郎 | premium |
| 4 | 山田 次郎 | standard |
| 5 | 伊藤 三郎 | free |
| order_id | user_id | status |
|---|---|---|
| 101 | 1 | completed |
| 102 | 1 | completed |
| 103 | 2 | pending |
| 104 | 3 | completed |
| 105 | 4 | cancelled |
| user_id | name | plan |
|---|---|---|
| 1 | 田中 太郎 | premium |
| 3 | 鈴木 一郎 | premium |
SELECT user_id, name, plan FROM users u WHERE EXISTS ( -- SQが1行でも返せばTRUE(短絡評価) SELECT 1 -- 何を返すかは問わない。行の存在だけを確認 FROM orders o WHERE o.user_id = u.user_id -- 外側の u.user_id を参照する相関条件(必須) AND o.status = 'completed' ) ORDER BY user_id; /* 実行順序(SQLの論理的な評価順): 1. FROM users u → 1行ずつ処理 2. EXISTS(...) → ユーザーごとにサブクエリを実行 3. SELECT user_id, name, plan → 通過した行を選択 4. ORDER BY user_id → user_id 昇順 */
LEGEND
① メインクエリ FROM users
FROM users uメインクエリの対象となる users テーブル全5行です。この各行に対してサブクエリが評価されます。| user_id | name | plan |
|---|---|---|
| 1 | 田中 太郎 | premium |
| 2 | 佐藤 花子 | free |
| 3 | 鈴木 一郎 | premium |
| 4 | 山田 次郎 | standard |
| 5 | 伊藤 三郎 | free |
SELECT 1 が慣用的です。WHERE o.user_id = u.user_id という外側クエリとの結合条件を書かないと、ordersに1件でも行があれば全ユーザーが通過します。相関条件は必須です。WHERE EXISTS (SELECT 1 FROM orders WHERE status='completed') は「ordersにcompletedが1件でもあれば」全ユーザーが通過します。外側テーブルとの結合条件 o.user_id = u.user_id を必ず書きましょう。SELECT * だと「全列を取得しようとしている」と誤解を招きます。SELECT 1 を使うことで「存在確認のみ」という意図が明確になります。NOT EXISTS述語 — NOT IN より安全な「存在しない」の確認パターン
WHERE NOT EXISTS (サブクエリ) はサブクエリが「0行を返す場合」にその行を残します。EXISTS の否定形で、「別テーブルに対応する行が存在しない」ことを確認するパターンです。
SELECT * FROM users u WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.user_id -- 相関条件 );
users テーブルから、status が 'completed' の注文が1件もないユーザーを NOT EXISTS で取得してください。取得列は user_id, name, plan、user_id 昇順で返してください。
| user_id | name | plan |
|---|---|---|
| 1 | 田中 太郎 | premium |
| 2 | 佐藤 花子 | free |
| 3 | 鈴木 一郎 | premium |
| 4 | 山田 次郎 | standard |
| 5 | 伊藤 三郎 | free |
| order_id | user_id | status |
|---|---|---|
| 101 | 1 | completed |
| 102 | 1 | completed |
| 103 | 2 | pending |
| 104 | 3 | completed |
| 105 | 4 | cancelled |
| user_id | name | plan |
|---|---|---|
| 2 | 佐藤 花子 | free |
| 4 | 山田 次郎 | standard |
| 5 | 伊藤 三郎 | free |
SELECT user_id, name, plan FROM users u WHERE NOT EXISTS ( -- SQが0行を返す(行が存在しない)場合に通過 SELECT 1 FROM orders o WHERE o.user_id = u.user_id -- 外側列を参照する相関条件(必須) AND o.status = 'completed' ) ORDER BY user_id; /* 実行順序(SQLの論理的な評価順): 1. FROM users u → 1行ずつ処理 2. NOT EXISTS(...) → ユーザーごとにサブクエリを実行 3. SELECT user_id, name, plan → 通過行を選択 4. ORDER BY user_id → user_id 昇順 */
LEGEND
① メインクエリ FROM users
FROM users uメインクエリの対象となる users テーブル全5行です。この各行に対してNOT EXISTSサブクエリが実行されます。| user_id | name | plan |
|---|---|---|
| 1 | 田中 太郎 | premium |
| 2 | 佐藤 花子 | free |
| 3 | 鈴木 一郎 | premium |
| 4 | 山田 次郎 | standard |
| 5 | 伊藤 三郎 | free |
LEFT JOIN orders o ON u.user_id=o.user_id AND o.status='completed' WHERE o.user_id IS NULL でも同じ結果になります。ORMが生成するクエリでよく見かけるパターンです。WHERE user_id NOT IN (SELECT user_id FROM orders) は全件0件になります。NOT EXISTS に変えるだけで正しく動きます。これが実務で最もよく起きるバグのひとつです。WHERE o.user_id = u.user_id を書かないと、ordersに1件でも行があれば全ユーザーが除外されます。相関条件は必須です。NOT EXISTS (SELECT 1 FROM orders WHERE user_id = u.user_id AND status='completed' AND ordered_at >= NOW() - INTERVAL '30 days') のように期間条件をサブクエリ内に入れるだけで実現できます。NOT EXISTS は条件の柔軟性が高く、実務バッチクエリの定番パターンです。EXISTS 複合条件 — 複数テーブルを横断する存在確認(商品×カテゴリ×注文)
EXISTS の内側では複数テーブルを JOIN して、より複雑な「存在確認」ができます。「この商品カテゴリの商品を注文したユーザー」のように2段階の関係を1回のEXISTSで確認するパターンです。
WHERE EXISTS ( SELECT 1 FROM orders o JOIN products p ON o.product_id = p.product_id WHERE o.user_id = u.user_id -- 外側との相関条件 AND p.category = 'スマートフォン' -- カテゴリ条件 )
users テーブルから、カテゴリが 'PC' の商品を1件でも注文したことがあるユーザーを取得してください。取得列は user_id, name, plan、user_id 昇順で返してください。
| user_id | name | plan |
|---|---|---|
| 1 | 田中 太郎 | premium |
| 2 | 佐藤 花子 | free |
| 3 | 鈴木 一郎 | premium |
| 4 | 山田 次郎 | standard |
| order_id | user_id | product_id | status |
|---|---|---|---|
| 101 | 1 | 1 | completed |
| 102 | 1 | 2 | completed |
| 103 | 2 | 3 | pending |
| 104 | 3 | 4 | completed |
| 105 | 4 | 2 | cancelled |
| product_id | name | category |
|---|---|---|
| 1 | スマートフォン X | スマートフォン |
| 2 | スマートブック Pro | PC |
| 3 | タブレット Air | タブレット |
| 4 | ワイヤレスイヤホン | アクセサリ |
| user_id | name | plan |
|---|---|---|
| 1 | 田中 太郎 | premium |
| 4 | 山田 次郎 | standard |
SELECT user_id, name, plan FROM users u WHERE EXISTS ( SELECT 1 FROM orders o JOIN products p ON o.product_id = p.product_id -- 商品情報と結合 WHERE o.user_id = u.user_id -- 外側の users との相関条件 AND p.category = 'PC' -- PCカテゴリの商品を注文しているか ) ORDER BY user_id; /* 実行順序(SQLの論理的な評価順): 1. FROM users u → 1行ずつ処理 2. EXISTS(...) → ユーザーごとに PC 注文の有無を判定 3. SELECT user_id, name, plan → 通過行を選択 4. ORDER BY user_id → user_id 昇順 */
LEGEND
① メインクエリ FROM users
FROM users uメインクエリの対象となる users テーブルです。| user_id | name | plan |
|---|---|---|
| 1 | 田中 太郎 | premium |
| 2 | 佐藤 花子 | free |
| 3 | 鈴木 一郎 | premium |
| 4 | 山田 次郎 | standard |
WHERE user_id IN (SELECT o.user_id FROM orders o JOIN products p ON o.product_id=p.product_id WHERE p.category='PC') と書いても同じ結果です。ただし EXISTS のほうがNULLに対して安全です。WHERE EXISTS (...) AND EXISTS (...) のように複数のEXISTSをANDで連結すると「AもBも注文したユーザー」を取得できます。INのサブクエリで同じことをしようとすると複雑なINTERSECTが必要になり、EXISTSのほうが自然に書けます。WHERE user_id = u.user_id と書いて結合が曖昧になるケースがあります。EXISTS内で使うテーブルには必ずエイリアスを付け、o.user_id = u.user_id のように明示しましょう。AND NOT EXISTS (SELECT 1 FROM orders o2 WHERE o2.user_id=u.user_id AND o2.product_id=X) を追加すれば「PCを買ったが商品Xはまだ買っていないユーザー」にも絞れます。述語の組み合わせで複雑なビジネス条件を表現できます。複合述語の総合問題 — 比較・IN・EXISTS を組み合わせた実務クエリ
実務のクエリは複数の述語を組み合わせて複雑な条件を表現します。比較述語で数値範囲を絞り、IN で特定カテゴリを選び、EXISTS で注文の存在を確認するという3段階フィルタのパターンを学びます。
WHERE p.price >= 50000 -- ① 比較述語:価格範囲 AND p.category IN ('PC', 'タブレット') -- ② IN述語:カテゴリ選択 AND EXISTS ( -- ③ EXISTS述語:注文の存在確認 SELECT 1 FROM orders o WHERE o.product_id = p.product_id AND o.status = 'completed' )
products テーブルから、price が 50,000 以上かつcategory が 'PC' または 'タブレット'の商品のうち、completedの注文が1件以上ある商品を取得してください。取得列は product_id, name, category, price、price 降順で返してください。
| product_id | name | category | price | stock |
|---|---|---|---|---|
| 1 | スマートフォン X | スマートフォン | 89800 | 50 |
| 2 | スマートブック Pro | PC | 128000 | 0 |
| 3 | タブレット Air | タブレット | 64800 | 30 |
| 4 | ワイヤレスイヤホン | アクセサリ | 12800 | 100 |
| 5 | USBケーブル | アクセサリ | 980 | 200 |
| 6 | ゲーミングPC Pro | PC | 198000 | 5 |
| order_id | product_id | status |
|---|---|---|
| 101 | 1 | completed |
| 102 | 2 | completed |
| 103 | 3 | pending |
| 104 | 6 | completed |
| 105 | 2 | cancelled |
| product_id | name | category | price |
|---|---|---|---|
| 6 | ゲーミングPC Pro | PC | 198000 |
| 2 | スマートブック Pro | PC | 128000 |
SELECT product_id, name, category, price FROM products p WHERE p.price >= 50000 -- ① 比較述語:5万円以上(インデックス有効) AND p.category IN ('PC', 'タブレット') -- ② IN述語:対象カテゴリのみ AND EXISTS ( -- ③ EXISTS述語:completed注文が存在するか SELECT 1 FROM orders o WHERE o.product_id = p.product_id -- 外側の商品との相関条件 AND o.status = 'completed' -- 完了済み注文のみ確認 ) ORDER BY p.price DESC; /* 実行順序(SQLの論理的な評価順): 1. FROM products p → 6行読み込み 2. WHERE p.price のしきい値 → 価格で絞り込み 3. AND p.category IN (...) → カテゴリで絞り込み 4. AND EXISTS (...) → completed 注文ありで絞り込み 5. SELECT product_id, name, category, price → 4列を選択 6. ORDER BY p.price DESC → 価格降順 */
LEGEND
① メインクエリ FROM products
FROM products pメインクエリの対象となる products テーブル全6行です。3段階のフィルタを順に適用していきます。| product_id | name | category | price |
|---|---|---|---|
| 1 | スマートフォン X | スマートフォン | 89800 |
| 2 | スマートブック Pro | PC | 128000 |
| 3 | タブレット Air | タブレット | 64800 |
| 4 | ワイヤレスイヤホン | アクセサリ | 12800 |
| 5 | USBケーブル | アクセサリ | 980 |
| 6 | ゲーミングPC Pro | PC | 198000 |
AND は OR より優先度が高いです。WHERE A OR B AND C は WHERE A OR (B AND C) と解釈されます。意図通りに動かすために、複数の述語を組み合わせる際は括弧で明示的にグループ化する習慣が重要です。WHERE price >= 50000 AND category = 'PC' OR category = 'タブレット' は WHERE (price >= 50000 AND category = 'PC') OR category = 'タブレット' と解釈されます。タブレットの価格条件が無効になる意図せぬ結果になります。括弧を使って category IN ('PC', 'タブレット') と書くことで解決します。