Treasure Data - Support Engineering Team blog

トレジャーデータのサポートエンジニアリングチームのブログです。

NOT INを使うと結果がおかしい!

こんにちは、Treasure Dataサポートの伊藤です。

NOT IN を使ったときに何故か1件も抽出できないという挙動について紹介します。

NOT IN について

念の為 NOT IN の使い方について復習しましょう。 簡単でわかりやすい使い方は下記になります。

WITH dataset AS (
SELECT *
  FROM (VALUES
          (1, 'hoge'), -- 結果として返されない
          (2, 'fuga')  -- 結果として返される
       ) AS t(user_id, name)
)
-- ここまでテスト用テーブル定義
SELECT *
  FROM dataset
 WHERE user_id NOT IN (1, 3)
;

事前にテーブルを用意しなくて済むようにクエリ内でテーブルを定義しており、下記テーブルとなります。

user_id name
1 hoge
2 fuga

もちろん上記クエリは結果としてuser_idカラムが2となるレコードのみ返されます。

条件として指定したい 1 や 2 という値を都度書き換える運用は現実的ではないため、基本的には下記のようにサブクエリを NOT IN を組み合わせて使うことが多いでしょう。

WITH dataset AS (
SELECT *
  FROM (VALUES
          (1, 'hoge'), -- 結果として返されない
          (2, 'fuga')  -- 結果として返される
       ) AS t(user_id, name)
)
-- ここまでテスト用テーブル定義
SELECT user_id, name
  FROM dataset
 WHERE user_id NOT IN (SELECT user_id
                        FROM (VALUES
                                (1, 'tokyo'),
                                (3, 'kanagawa')
                             ) AS t(user_id, location)
                      )
;

サブクエリは1と3を返します。一方datasetテーブルのuser_idは1と2となるのでuser_idが2となるレコードが結果となります。この挙動は想定通りかと思います。

なぜか1件も結果を返さない

さて、本記事のメインとなる事象について説明していきます。

下記サンプルクエリを見てください。 先程のクエリと差分は NOT IN のサブクエリが2行から3行返すようになっただけです。

WITH dataset AS (
SELECT *
  FROM (VALUES
          (1, 'hoge'),
          (2, 'fuga')  -- 結果として返されそうだが・・・
       ) AS t(user_id, name)
)
-- ここまでテスト用テーブル定義
SELECT user_id, name
  FROM dataset
 WHERE user_id NOT IN (SELECT user_id
          FROM (VALUES
                  (1, 'tokyo'),
                  (3, 'kanagawa'),
                  (null, 'chiba') -- 差分
               ) AS t(user_id, location)
       )
;

user_idが2となる結果をサブクエリが返さないため、結果として user_id が2となるレコードが返されそうですよね。 ですがセクションのタイトルにもある通り、このクエリの結果は0件となります。

原因

user_id!=1 AND user_id!=3 AND user_id!=null の評価結果が TRUE となるレコードが結果として返されると考えると良いでしょう。

user_idが2の場合 2!=1 AND 2!=3 AND 2!=nullTRUE になるかどうか確認が行われ、 TRUE AND TRUE AND NULL --> NULL となりますので、user_idが2のレコードは結果として返されません。

見ていただいたらわかるかと思いますが NULL が原因です。NULLかどうか評価する際は IS NULLIS NOT NULL を使いますよね。 NOT IN のときはそのような考慮がされないので、その結果期待した結果が得られません。

下記SQLで確認して見ると机上ではなく実際に動作確認できるためわかりやすいかもしれませんね。

SELECT (true AND true AND null) AS col1;

回避策

原因がサブクエリが NULL を返すことだったので、下記のように IS NOT NULL にて除外することで期待する結果を取得することができます。

WITH dataset AS(
SELECT *
  FROM (VALUES
          (1, 'hoge'),
          (2, 'fuga')
       ) AS t(user_id, name)
)
-- ここまでテスト用テーブル定義
SELECT user_id, name
  FROM dataset
 WHERE user_id NOT IN (SELECT user_id
                         FROM (VALUES
                                 (1, 'tokyo'),
                                 (3, 'kanagawa'),
                                 (null, 'chiba')
                              ) AS t(user_id, location)
                        WHERE user_id IS NOT NULL -- 回避策として追加 
       )
;

もしくは下記のように NOT EXISTS で代替すると良いでしょう。

WITH dataset AS(
SELECT *
  FROM (VALUES
          (1, 'hoge'),
          (2, 'fuga')
       ) AS t(user_id, name)
)
-- ここまでテスト用テーブル定義
SELECT user_id, name
  FROM dataset
 WHERE NOT EXISTS (SELECT 1
                     FROM (VALUES
                             (1, 'tokyo'),
                             (3, 'kanagawa'),
                             (null, 'chiba')
                          ) AS t(user_id, location)
                    WHERE dataset.user_id=t.user_id
                 )
;

いかがでしたでしょうか?

直感と実際の挙動が異なるので、遭遇した際に混乱してしまうのではないかと思います。 本件に限らず、 NULL は想定しない結果の原因となることが多いため、疑うポイントとして認識しておくと良いかもしれませんね。