基本的なことだけど、久しぶりにハマって2時間くらい溶かしてしまったので、自分用メモ。大体のことは以下の本に書いてある。
INとNULL
普通のプログラミング言語であれば、真偽値(BOOLEAN)はTRUE
or FALSE
の二通り。しかし、SQLの世界ではNULL
が入ってくるため話がややこしくなる。いくつか例があるが、排中律が成立しないことが挙げられる。具体的なクエリとしては以下のようなもの。
SELECT 1 IN (1) AS a, 2 IN (1) AS b, 2 NOT IN (1) AS c, 1 IN (1, NULL) AS d, 2 IN (1, NULL) AS e, 2 NOT IN (1, NULL) AS f
eとfがやっかいで、同一の条件でIN
とNOT IN
を書いているので、どちらかがTRUE
でどちらかがFALSE
を期待してしまうが、結果はそんなことはなくてどちらもNULL
になる。IN
を同値変換すると=
とOR
を使った形(1 = 2 OR 1 = NULL
)にできるが、変換された後ではIS NULL
を使って比較をしていないため、こういったことが起きる。
IN
に与えているリストの中に「一つでも」NULL
が入っていると、結果がNULL
になってしまう。この例は単純なのですぐに気付けるが、クエリをあれこれ組み合わせていると結構気付けなかったりする。
今回はまったケース
で、はまったケースがこちら(例なので、小さいサンプル)。でかいデータ(data
)というものがあって、それぞれのカラムはNULLにならないと私は思っていた。このデータを適当にGROUP BY
して、各行に対して文字列結合した結果にIN
を使った結果、TRUE
でもFALSE
でもなくNULL
が登場してしまう。GROUP BY
した結果が数万件単位あって、目視で見たところ異常なさそうと判断したのがよくなかった。
WITH data AS ( SELECT "a" AS a, "b" AS b, UNION ALL SELECT NULL AS a, "bb" AS b, UNION ALL SELECT "aa" AS a, NULL AS b, UNION ALL SELECT NULL AS a, NULL AS b, ), tmp AS ( SELECT a, b FROM data GROUP BY a, b ) SELECT "a:b" IN (SELECT a || ":" || b FROM tmp) AS x, "aa:b" IN (SELECT a || ":" || b FROM tmp) AS y, "aa:b" NOT IN (SELECT a || ":" || b FROM tmp) AS z
敗因としては元データにNULL
がないと勝手に断定したことが原因で、これを防ぐためには
- 元データに
NULL
が入るケースがあるか確認する- dbtのsourceのテストとかでも何でも
GROUP BY
の条件にIS NOT NULL
を忘れずに付けていくcoalesce
関数などを使って、NULL
の場合には他の値を入れていく
とかだろうか。
怖い例
古いテーブルを定期的に削除したいが、削除してはいけないテーブルがあって、それらはホワイトリスト形式で管理されている。削除候補対象に対してNOT IN ホワイトリスト
のようなことをやっていると、削除が全然されなかったり、(消してはいけないテーブルも含めて)全削除されてしまう、ということもあり得る。特に後者はやばいので、ホワイトリストの中にNULLが入っていないとか、削除候補と実際に削除する件数が一致していないか(全削除になっていないか)など、防衛的に振る舞うなどしたほうがよい*1。
所感
こういうのを人間がやるの、まあ間違えないほうが難しいよなって気がするので、型付きSQLみたいなのが出てきて欲しい。この結果はBOOLEAN
じゃなくてOPTIONAL<BOOLEAN>
みたいなやつ。BigQueryだとカラムのSchema(モード)にNon-NULLを定義することはできるけど、SQLレベルでそれが判断したい。SQLはきっと10年後も生き残っているツールなんだろうけど、10年後も人間がこんなところで悩んでいる未来にはいたくないな...。調べればきっとすでにアイディアとしてはあるんだろうけど、まあ流行らなかったんだろうなぁ。以上、愚痴でした。
*1:やらかしたわけではないです