🍣

EXISTS句について - なんで「すべて」を表現するのに二重否定が必要なのか

に公開

参考文献

本記事は以下の書籍を参考に、学んだ内容を自分なりにまとめたものです。

はじめに

EXISTS句の基本から、全称量子化を表現するための二重否定の必要性まで、振り返る。特に「なぜEXISTS + 肯定条件ではダメなのか?」という疑問に対して、深掘る。

述語の定義

書籍によると、SQLにおける述語とは戻り値が真偽値(Boolean型)になる関数や式のこと。WHERE句に述語を記述すると、その述語がtrueを返す行だけがクエリの対象となる。

比較演算子、LIKE、BETWEEN、IN、EXISTSなどがすべて述語に該当する:

WHERE price > 1000
WHERE name LIKE 'A%'
WHERE age BETWEEN 20 AND 30
WHERE category IN ('食品', '飲料')
WHERE EXISTS (SELECT * FROM ...)

1階の述語と2階の述語

書籍で初めて知った概念。述語は引数として何を取るかによって階層が分かれる。

1階の述語: スカラ値(単一の値)を引数にとる

WHERE price > 1000  -- priceという単一の値を評価
WHERE status = 'active'

2階の述語: 集合(行の集まり)を引数にとる

WHERE EXISTS (
    SELECT * FROM orders  -- 行の集合を引数にとる
    WHERE orders.customer_id = customers.id
)

EXISTS句は「2階の述語」であり、引数としてサブクエリ(=行の集合)を取る。この特性が、他の述語にはない表現力を生み出している。

EXISTS句の基本

構文と動作

EXISTS句の基本構文:

WHERE EXISTS (サブクエリ)

EXISTS句は、サブクエリが1行以上の結果を返せばtrue、0行ならfalseを返す。つまり、「条件を満たす行が存在するか?」を判定する。

SELECT句に何を書いても同じ

SELECT句に何を指定しても結果は同じである

-- これらはすべて同じ結果
WHERE EXISTS (SELECT * FROM orders WHERE ...)
WHERE EXISTS (SELECT 1 FROM orders WHERE ...)
WHERE EXISTS (SELECT order_id FROM orders WHERE ...)
WHERE EXISTS (SELECT 'X' FROM orders WHERE ...)

理由は、EXISTSが「行が存在するかどうか」だけを評価し、SELECT句で指定した内容は完全に無視するから。慣習的にはSELECT *SELECT 1がよく使われる。

存在量子化と全称量子化

論理学の概念である「量子化」ががEXISTS句を深く理解するカギだった。

存在量子化(∃): 「少なくとも1つ存在する」

存在量子化は、「条件Pを満たすxが少なくとも1つ存在する」という命題。記号では∃x P(x)と表す。

SQLでは、EXISTS句がこの存在量子化を直接表現する:

-- 「80点以上の科目が少なくとも1つ存在する学生」
WHERE EXISTS (
    SELECT *
    FROM test_score ts2
    WHERE ts1.student_id = ts2.student_id
      AND ts2.score >= 80
)

全称量子化(∀): 「すべてが条件を満たす」

全称量子化は、「すべてのxが条件Pを満たす」という命題。記号では∀x P(x)と表す。

SQLには全称量子化を直接表現する構文が存在しない

ド・モルガンの法則を使って変形する必要がある

∀x P(x) ≡ ¬∃x ¬P(x)
「すべてのxがPを満たす」≡「Pを満たさないxは存在しない」

SQLで表現すると:

-- 「すべての科目が80点以上の学生」
WHERE NOT EXISTS (
    SELECT *
    FROM test_score ts2
    WHERE ts1.student_id = ts2.student_id
      AND ts2.score < 80  -- 否定条件
)

【疑問点】なぜEXISTS + 肯定条件ではダメなのか?

書籍を読んだ時点で、「すべての科目が80点以上」を表現するのになぜ二重否定が必要なのか、直感的に腑に落ちなかった。

自分の中で浮かんだ疑問:

「二重否定を使わず、EXISTS句に肯定条件を書けば同じことが表現できるのでは?」

この疑問を解消するために、実際にクエリを書いて検証してみた。

検証用のテーブル

以下のようなテストの点数テーブルを用意:

CREATE TABLE test_score (
    student_id INTEGER NOT NULL,
    subject VARCHAR(20) NOT NULL,
    score INTEGER NOT NULL,
    PRIMARY KEY (student_id, subject),
    CHECK (score >= 0 AND score <= 100)
);

INSERT INTO test_score VALUES
(100, 'math', 100),
(100, 'japanese', 80),
(100, 'science', 80),
(200, 'math', 80),
(200, 'japanese', 95),
(300, 'math', 40),
(300, 'japanese', 90),
(300, 'social', 55),
(400, 'math', 80);

テーブルの状態:

student_id | subject  | score
-----------|----------|------
100        | math     | 100
100        | japanese | 80
100        | science  | 80
200        | math     | 80
200        | japanese | 95
300        | math     | 40   ← 80点未満
300        | japanese | 90
300        | social   | 55   ← 80点未満
400        | math     | 80

目標: 「すべての科目が80点以上である学生」を取得したい

正しい結果はstudent_id 100と200の2人。

パターンA: EXISTS + 肯定条件で書いてみた

まず、自分の直感に従ってEXISTS + 肯定条件で書いてみた:

SELECT DISTINCT student_id
FROM test_score ts1
WHERE EXISTS (
    SELECT *
    FROM test_score ts2
    WHERE ts1.student_id = ts2.student_id
      AND ts2.score >= 80  -- 肯定条件
);

実行結果:

student_id
----------
100
200
300  ← 誤って含まれている!
400

student_id 300が結果に含まれてしまった。これは間違い。

なぜ間違った結果になったのか?

このクエリが意味するのは:
「80点以上の科目が少なくとも1つ存在する学生」

student_id 300の場合:

  • math: 40点(80点未満)
  • japanese: 90点(80点以上) ← これが存在するだけでtrueになる
  • social: 55点(80点未満)

EXISTSは「条件を満たす行が1つでもあればtrue」を返すため、japaneseの90点だけでtrueになってしまう。mathやsocialが80点未満であることは無視される。

つまり、自分の直感は間違っていた

パターンB: NOT EXISTS + 否定条件

書籍で説明されていた方法:

SELECT DISTINCT student_id
FROM test_score ts1
WHERE NOT EXISTS (
    SELECT *
    FROM test_score ts2
    WHERE ts1.student_id = ts2.student_id
      AND ts2.score < 80  -- 否定条件
);

実行結果:

student_id
----------
100
200

正しく、student_id 100と200だけが取得できた。

なぜ正しい結果になるのか?

このクエリが意味するのは:
「80点未満の科目が1つも存在しない学生」

student_id 300の場合:

  • math: 40点(80点未満) ← この行が存在する
  • japanese: 90点
  • social: 55点(80点未満) ← この行も存在する

サブクエリは「score < 80」の行を探すので、mathとsocialがヒットする。つまりサブクエリは結果を返す(存在する)。それをNOTで否定するのでfalseになる。結果として、student_id 300は除外される。

命題の関係性

命題 意味 SQL表現
∀x P(x) すべてのxがPを満たす NOT EXISTS (... NOT P)
∃x P(x) Pを満たすxが少なくとも1つ存在 EXISTS (... P)
¬∀x P(x) すべてのxがPを満たすわけではない EXISTS (... NOT P)
¬∃x P(x) Pを満たすxが1つも存在しない NOT EXISTS (... P)

ここで重要なのは、全称量子化(∀)と存在量子化(∃)は別物だということ。

ド・モルガンの法則

∀x P(x) ≡ ¬∃x ¬P(x)

日本語で言うと:

「すべてのxがPを満たす」≡「Pを満たさないxは存在しない」

SQLでの対応:

「すべての科目が80点以上」
  ↓ ド・モルガンの法則で変形
「80点未満の科目が存在しない」
  ↓ SQLで表現
NOT EXISTS (SELECT * FROM test_score WHERE score < 80)

この変形が必要な理由は、SQLには∀(すべて)を直接表現する構文がないから。

対偶との違い

最初、これを「対偶」と混同していたが、ニュアンスが違った

対偶は条件文(P→Q)に対する変形:

P → Q ≡ ¬Q → ¬P
「PならばQ」≡「QでないならばPでない」

一方、全称量子化は条件文ではなく量化された命題。対偶の概念は直接適用できない。

ここで使われているのは対偶ではなく、ド・モルガンの法則による論理的同値変形

疑問への答え

結論として:

  1. EXISTS + 肯定条件 → 存在量子化(∃)を表現 → 「少なくとも1つ」の意味
  2. NOT EXISTS + 否定条件 → 全称量子化(∀)を表現 → 「すべて」の意味

SQLで「すべて」を表現するには、二重否定を使ってド・モルガンの法則による変形を行う必要がある。直感的ではないが、論理的には正しい。

具体的な使用例

基本パターン

「すべての科目が80点以上の学生」を取得するクエリ:

SELECT DISTINCT student_id
FROM test_score ts1
WHERE NOT EXISTS (
    SELECT *
    FROM test_score ts2
    WHERE ts1.student_id = ts2.student_id
      AND ts2.score < 80
);

読み方:

  1. 外側のクエリで学生を1人ずつ確認
  2. その学生について、「80点未満の科目が存在するか?」をチェック
  3. 存在しない(NOT EXISTS)場合のみ、その学生を結果に含める

応用: CASE式を使った複雑な条件

科目ごとに異なる基準を設定したい場合、CASE式を組み合わせる。

例: 「数学は80点以上、国語は100点、理科は90点以上が必要」:

SELECT DISTINCT student_id
FROM test_score ts1
WHERE NOT EXISTS (
    SELECT *
    FROM test_score ts2
    WHERE ts1.student_id = ts2.student_id
      AND (
          (ts2.subject = 'math' AND ts2.score < 80)
          OR (ts2.subject = 'japanese' AND ts2.score < 100)
          OR (ts2.subject = 'science' AND ts2.score < 90)
      )
);

CASE式を使った書き方:

SELECT DISTINCT student_id
FROM test_score ts1
WHERE NOT EXISTS (
    SELECT *
    FROM test_score ts2
    WHERE ts1.student_id = ts2.student_id
      AND 1 = CASE
          WHEN ts2.subject = 'math' AND ts2.score < 80 THEN 1
          WHEN ts2.subject = 'japanese' AND ts2.score < 100 THEN 1
          WHEN ts2.subject = 'science' AND ts2.score < 90 THEN 1
          ELSE 0
      END
);

このCASE式のパターンは、「違反条件のマッチング」として理解できる。

HAVINGとの比較

全称量子化はHAVING句でも表現できる。

HAVINGでの書き方

同じ「すべての科目が80点以上の学生」をHAVINGで表現:

SELECT student_id
FROM test_score
GROUP BY student_id
HAVING MIN(score) >= 80;

「その学生の最低点が80点以上」という条件で、「すべての科目が80点以上」を表現している。

別の書き方:

SELECT student_id
FROM test_score
GROUP BY student_id
HAVING COUNT(*) = COUNT(CASE WHEN score >= 80 THEN 1 END);

「全科目数 = 80点以上の科目数」という条件。

EXISTSとHAVINGの違い

EXISTS(NOT EXISTS)の強み: 短絡評価

WHERE NOT EXISTS (
    SELECT *
    FROM test_score ts2
    WHERE ts1.student_id = ts2.student_id
      AND ts2.score < 80
)

NOT EXISTSは、条件を満たす行(80点未満の科目)が1つでも見つかった時点で評価を打ち切れる

例えば、student_id 300の場合:

  1. math(40点)をチェック → 80点未満が見つかった
  2. この時点で条件を満たさないことが確定
  3. japaneseやsocialをチェックする必要がない

HAVINGの弱み: 全行スキャン

GROUP BY student_id
HAVING MIN(score) >= 80

HAVINGは集約関数(MIN)を使うため、各学生のすべての科目をスキャンして最小値を計算する必要がある

表現力の違い

EXISTS句の方が、複雑な条件を表現しやすい。

科目ごとに異なる条件を指定する場合:

EXISTS(表現しやすい)

WHERE NOT EXISTS (
    SELECT *
    FROM test_score ts2
    WHERE ts1.student_id = ts2.student_id
      AND (
          (ts2.subject = 'math' AND ts2.score < 80)
          OR (ts2.subject = 'japanese' AND ts2.score < 80)
          OR (ts2.subject = 'science' AND ts2.score < 90)
      )
)

HAVING(表現が困難)

GROUP BY student_id
HAVING 
    MIN(CASE WHEN subject = 'math' THEN score END) >= 80
    AND MIN(CASE WHEN subject = 'japanese' THEN score END) >= 80
    AND MIN(CASE WHEN subject = 'science' THEN score END) >= 90

可読性が低く、複雑になる。

自分なりの使い分け基準

観点 EXISTS HAVING
パフォーマンス 短絡評価が効く 全行スキャンが必要
表現力 複雑な条件も書きやすい 複雑な条件は困難
直感性 二重否定で読みにくい 意図が分かりやすい
取得情報 元の行の情報を保持 集約されてしまう

自分が採用する基準:

  • シンプルな条件で直感性を重視 → HAVING
  • 複雑な条件やパフォーマンス重視 → EXISTS
  • 元の行の詳細情報も必要 → EXISTS

まとめ

理解できたポイント

  1. EXISTS句は2階の述語で、行の集合を引数にとる
  2. SELECT句は無視されるので何を書いても結果は同じ
  3. 存在量子化(少なくとも1つ)と全称量子化(すべて)は別物
  4. SQLには∀がないので、全称量子化は二重否定で表現する
  5. ド・モルガンの法則: ∀x P(x) ≡ ¬∃x ¬P(x)

二重否定の読み方

NOT EXISTS + 否定条件を見たら、以下のように読み替える:

WHERE NOT EXISTS (
    SELECT * FROM ... WHERE [否定条件]
)

↓ 読み替え

「[否定条件]を満たす行が存在しない」
= 「[肯定条件]をすべての行が満たす」

例:

WHERE NOT EXISTS (
    SELECT * FROM test_score ts2
    WHERE ts1.student_id = ts2.student_id
      AND ts2.score < 80  -- 否定条件
)

↓ 読み替え

「80点未満の科目が存在しない」
= 「すべての科目が80点以上」

Discussion