Web開発部のkonagaです。
最近、業務でSQLの書き方に悩むことが多く、特に全称量化をどう表現するか試行錯誤していました。
SQLに慣れている方にとっては今更な話かもしれませんが、全称量化の表現方法とNULLに関する注意点について調べたのでまとめていこうと思います。
※検証時には手元にあったPostgreSQL9.4を利用しています。RDBMSの種類やバージョン次第では正しく動作しない可能性があることをご了承ください。
はじめに:全称量化子について
「全てのxが条件Pを満たす」ことを表現する記号のことを全称量化子といいます。
この記号は、allの頭文字Aを反転させた∀
で表します。
……これだけだとよく分からないので、以下のような例を挙げてみます。
- 生徒全員がテストで60点以上を獲得しているクラス
- 全ての工程が完了しているプロジェクト
全称量化子によって、このようなクラスやプロジェクトを選択することができます。
ちなみに「条件Pを満たすxが存在する」ことを表現する記号も存在していて、これは存在量化子といいます。
この記号は、existの頭文字Eを反転させた∃
で表します。
テーブルを設けた例
もうすこし話を進めるため、とある飲食店経営企業が存在し、店舗ごとのメニューとその値段を管理するmenus
テーブルが用意されているとします。
実際にはテーブルがもっと正規化されると思いますし、別店舗の同じ商品でこんなに値段が違うのも現実的ではないと思います。
あくまで例に過ぎない、ということでご容赦ください。
shop_name | drink | price |
---|---|---|
A店 | タピオカミルクティー | 400 |
A店 | タピオカ抹茶ミルクティー | 450 |
A店 | タピオカ豆乳 | 500 |
B店 | タピオカミルクティー | 550 |
B店 | タピオカ抹茶ミルクティー | 600 |
B店 | タピオカジャスミンティー | 600 |
C店 | タピオカミルクティー | 450 |
C店 | タピオカラテ | 500 |
C店 | タピオカマンゴーミルクティー | 550 |
このテーブルから「全てのドリンクが500円以下の店舗名」を検索すると、A店だけが返ってきます。
一部のドリンクが500円より高いC店は返ってきません。
これが「500円以下のドリンクが存在する店舗名」という検索内容だと、C店も返ってくるという違いが生じます。
SQLで全称量化を表現する
では「全てのドリンクが500円以下の店舗名」をSQLで表現していきたいと思います。
残念ながら、SQLには全称量化子に直接対応する述語が存在しません。
しかし、SQLの様々な構文を利用して、全称量化の表現をSQLに落とし込むことは可能です。
1. 対偶を取ってからNOT EXISTS
述語を使う
「全てのxが条件Pを満たす」という表現は、「条件Pを満たさないxが存在しない」という対偶と同値です。
「〜が存在しない」という表現についてはNOT EXISTS
述語が対応するため、対偶を取ることでSQLに書き下せそうですね。
具体的な検索内容を二重否定で言い換えてみます。
「全てのドリンクが500円以下の店舗名」 → 「500円より高いドリンクが存在しない店舗名」
この表現に対応するSQLは以下のように記述します。
SELECT DISTINCT shop_name
FROM menus m1
WHERE NOT EXISTS
(SELECT *
FROM menus m2
WHERE m2.price > 500
AND m2.shop_name = m1.shop_name);
このSQLは以下のような結果を返します。期待通りですね。
shop_name |
---|
A店 |
NOT EXISTS
を使ったSQLには二重否定特有の読みにくさが生じます。
一方で、店舗名の列にインデックスがある場合には利用してくれることがある、というメリットもあります。
クエリの実行速度を追求するなら選択肢に入る表現だと思います。
2. ALL
述語を使う
次はALL
述語を使って全称量化を行っていきます。
ALL
述語は各条件をANDで連結して評価します。
SELECT DISTINCT shop_name
FROM menus m1
WHERE 500
>= ALL (SELECT price
FROM menus m2
WHERE m2.shop_name = m1.shop_name);
例えば、A店に限って言えばWHERE句は以下のように展開できます。
WHERE (500 >= 400) AND (500 >= 450) AND (500 >= 500)
-- -> TRUE
こうして各店舗について500 >= ALL(...)
を評価した結果、唯一TRUEと評価されたA店が検索結果として返ってきます。
3. HAVING
句 + COUNT
関数を使う
HAVING
句の表現力は非常に強く、全称量化にも利用することができます。
私は今まで深く考えずにNOT EXISTS
を使っていればいいと思っていたのですが、記事執筆中にHAVING
の方が人間に優しいなと思い直しました。
SELECT shop_name
FROM menus
GROUP BY shop_name
HAVING COUNT(*) = SUM(CASE WHEN price <= 500 THEN 1 ELSE 0 END);
まずCASE
式ですが、これは値段が500円以下であるかどうかというフラグを付けているようなものです。
shop_name | drink | price | flag |
---|---|---|---|
A店 | タピオカミルクティー | 400 | 1 |
A店 | タピオカ抹茶ミルクティー | 450 | 1 |
A店 | タピオカ豆乳 | 500 | 1 |
B店 | タピオカミルクティー | 550 | 0 |
B店 | タピオカ抹茶ミルクティー | 600 | 0 |
B店 | タピオカジャスミンティー | 600 | 0 |
C店 | タピオカミルクティー | 450 | 1 |
C店 | タピオカラテ | 500 | 1 |
C店 | タピオカマンゴーミルクティー | 550 | 0 |
ここでSELECTするべきなのは「ドリンクのflagが全て1になっている」店舗名です。
この条件は「店舗ごとのドリンクの総数 = 店舗ごとのflagの総和」に置き換えることができます。
さらに「店舗ごとのドリンクの総数」はCOUNT
で行数をカウントすることで求められます。
最後にHAVING
で条件をまとめれば上のようなSQLが完成します。
追記(2019/08/23)
コメントで指摘をいただきましたが、後述するMAX
関数をHAVING
句と合わせて利用すれば、CASE式よりもさらに分かりやすく記述することができます。
SELECT shop_name
FROM menus
GROUP BY shop_name
HAVING MAX(price) <= 500;
ただしMAX
にはALL
やCOUNT(*)
と違い、NULLを除外するという特徴があります。後ほど説明します。
SQL表現についてのまとめ
以上、三通りの表現方法を紹介しました。
私の印象はこんな感じです。
NOT EXISTS
: 速くなることがあるとはいえ、二重否定のせいで可読性が低いALL
: 可読性はまずまずだがサブクエリがやや長いHAVING
+COUNT
: 簡潔に書けて可読性が高い
あくまで個人的な意見ですが、特別な考慮が必要なければHAVING
を使うのが良さそうだと思っています。
NULLと付き合う上での注意点
全称量化の表現には、テーブルにNULLが存在する場合に異なる結果を返すものが存在します。
その違いについて確認していきましょう。
1. 極値関数とALL
述語の違い
結論から
極値関数とALL
述語はそれぞれ全称量化に利用できますが、極値関数がNULLを除外する一方で、ALL
述語はNULLを除外しません。
では説明していきます。
極値関数MAX
による全称量化
先ほどALL
述語を利用して「全てのドリンク」について値段が500円以下であるか確認しましたが、そのうちの一つだけに着目して次のように言い換えることもできます。
「全てのドリンクが500円以下の店舗名」 → 「全てのドリンクのうち、最も高いドリンクが500円以下の店舗名」
これを表すには、極値関数のMAX
を利用します。
SELECT DISTINCT shop_name
FROM menus m1
WHERE 500
>= (SELECT MAX(price)
FROM menus m2
WHERE m2.shop_name = m1.shop_name);
shop_name |
---|
A店 |
こうしてみると、MAX
でもALL
述語と同じような全称量化が可能であるように思えます。
しかし、極値関数にはNULLを除外するという特徴があります。
極値関数とNULL
この特徴を確認するために、A店で販売されているドリンクのpriceを一部NULLにしてみましょう。
shop_name | drink | price |
---|---|---|
A店 | タピオカミルクティー | 400 |
A店 | タピオカ抹茶ミルクティー | 450 |
A店 | タピオカ豆乳 | NULL |
B店 | タピオカミルクティー | 550 |
B店 | タピオカ抹茶ミルクティー | 600 |
B店 | タピオカジャスミンティー | 600 |
C店 | タピオカミルクティー | 450 |
C店 | タピオカラテ | 500 |
C店 | タピオカマンゴーミルクティー | 550 |
MAX
は引数からNULLを除外するため、500 >= NULL
という比較がされてUNKNOWNと評価されることはありません。
(>=
でNULLと比較した場合、結果はUNKNOWNになります)
WHERE 500
>= (SELECT MAX(price)
FROM menus m2
WHERE m2.shop_name = m1.shop_name);
-- A店についてサブクエリを評価
WHERE 500 >= 450
検索結果は変わらずA店のみです。
shop_name |
---|
A店 |
ALL
述語とNULL
一方、ALL
を利用したSQLを実行すると、検索結果は空になり、A店が選択されません。
shop_name |
---|
これはWHERE句が「UNKNOWNまたはFALSE」としか評価されないためです。
A店についてALL
を展開すると、500 >= NULL
が含まれるため、UNKNOWNと評価されることがわかります。
WHERE 500
>= ALL (SELECT price
FROM menus m2
WHERE m2.shop_name = m1.shop_name);
-- ALLを展開する
WHERE (500 >= 400) AND (500 >= 450) AND (500 >= NULL)
-- -> UNKNOWN
どちらの表現を使うべきか
ALL
述語と極値関数ではどちらを使うのが適切なのか、という話ですが、これは状況によると思います。
SQLの筋としてどちらが正しいかというよりも、どちらの方が検索したい条件に合うかで選べば良さそうです。
もう一度テーブルを確認してみましょう。
shop_name | drink | price |
---|---|---|
A店 | タピオカミルクティー | 400 |
A店 | タピオカ抹茶ミルクティー | 450 |
A店 | タピオカ豆乳 | NULL |
まず、今回のNULLは「タピオカ豆乳は販売されているが、その値段がいくらになったのか判明していない」という意味で設定されていると考えることができます。
実際の値段は500円かもしれないし700円かもしれません。500円以下であるかは判断ができません。
そして判断ができない以上、タピオカ豆乳を販売しているA店を「全てのドリンクが500円以下の店舗名」と言うことはできません。
この考え方だと、UNKNOWNを含めるALL
述語を使う方が適切な結果が得られます。
一方で、「タピオカ豆乳は販売前の新メニューなのでpriceがNULLになっている」「店頭に出ていないので『全てのドリンクが500円以下の店舗名』を探すときには無視して良い」という考え方(というよりはデータ管理の仕方)もあるかもしれません。
この場合はNULLを無視することになります。
表現としては極値関数MAX
を使うこともできるのですが、それよりもWHERE句でpriceがNULLの行を弾いた方が意味が分かりやすく、親切ではないかと思っています。
好みの範囲といえばそれまでかもしれませんが。
SELECT DISTINCT shop_name
FROM menus m1
WHERE 500
>= ALL (SELECT price
FROM menus m2
WHERE m2.shop_name = m1.shop_name
AND m2.price IS NOT NULL);
書き方に関わらず、NULLの存在によってALL
と極値関数が同じ結果を返さなくなることは、把握しておいて損はないと思います。
2. COUNT(*)
とCOUNT(列名)
の違い
結論から
COUNT
関数には以下の二種類があり、カウントする対象が異なります。
COUNT(*)
: 全行を数えるCOUNT(列名)
: ある列からNULLを除外した行を数える
……既視感がありますね?全称量化の文脈における二つのCOUNT
関数は、先ほどの「極値関数とALL
述語の違い」と同様にNULLを除外するかどうかが異なります。
COUNT(*)
とNULL
再びA店のドリンクのpriceを一部NULLにしておきます。
shop_name | drink | price |
---|---|---|
A店 | タピオカミルクティー | 400 |
A店 | タピオカ抹茶ミルクティー | 450 |
A店 | タピオカ豆乳 | NULL |
B店 | タピオカミルクティー | 550 |
B店 | タピオカ抹茶ミルクティー | 600 |
B店 | タピオカジャスミンティー | 600 |
C店 | タピオカミルクティー | 450 |
C店 | タピオカラテ | 500 |
C店 | タピオカマンゴーミルクティー | 550 |
COUNT(*)
を利用したSQLの場合は、検索結果は空になり、A店が選択されません。
SELECT shop_name
FROM menus
GROUP BY shop_name
HAVING COUNT(*) = SUM(CASE WHEN price <= 500 THEN 1 ELSE 0 END);
HAVING
句をA店に限って展開し、どのような式が成立しているのか確認してみましょう。
-- COUNT(*)の場合のSQL
HAVING 3 = SUM((CASE WHEN 400 <= 500 THEN 1 ELSE 0 END),
(CASE WHEN 400 <= 450 THEN 1 ELSE 0 END),
(CASE WHEN 400 <= NULL THEN 1 ELSE 0 END));
-- WHENの不等式を評価
HAVING 3 = SUM((CASE WHEN (TRUE) THEN 1 ELSE 0 END),
(CASE WHEN (TRUE) THEN 1 ELSE 0 END),
(CASE WHEN (UNKNOWN) THEN 1 ELSE 0 END));
-- CASE式を評価する
HAVING 3 = SUM(1, 1, 0);
最後には3 = 2
となるため、HAVING
句はFALSEと評価されます。
COUNT(列名)
とNULL
一方、COUNT(price)
を利用したSQLの場合は、A店が検索結果に含まれます。
これはpriceがNULLとなっている行がカウントされないためです。
-- COUNT(price)の場合のSQL
SELECT shop_name
FROM menus
GROUP BY shop_name
HAVING COUNT(price) = SUM(CASE WHEN price <= 500 THEN 1 ELSE 0 END);
-- COUNT関数とCASE式を評価する
HAVING 2 = SUM(1, 1, 0);
HAVING
句はTRUEと評価されます。
この違いも知っておくと、あえてNULLを無視したい場合にはCOUNT(列名)
が使えるかと思います。
分かりやすいかは微妙なところですが、オシャレな書き方ですね。
おわりに
SQLの全称量化表現についてはあまり情報を見かけないので整理してみました。
興味を持った方は、参考文献の方が精度が高く内容が充実しているため、そちらを読むことをおすすめします。
参考文献
おたより発掘
相関サブクエリもあんまり使いたくないし、`SUM(CASE WHEN` もなるべく使いたくないし、悩ましい / “SQLで全称量化を表現する方法 & NULLと付き合う上での注意点” https://t.co/x9Hcu5NN39
— yancya (@yancya) August 22, 2019