SQL のメモ

SQL に関する偉大なる下記書籍を復習する機会があったため、その内容をメモしておきます。実行環境は BigQuery です。


目次。

水平展開

行から列(CASE 式)

テスト用のテーブルを用意。

CREATE OR REPLACE TABLE test.test_score AS
  SELECT
    LPAD(CAST(num AS STRING), 6, "0") AS student_id,
    CASE MOD(CAST((RAND()*10) AS INT64), 5)
      WHEN 0 THEN "A組"
      WHEN 1 THEN "B組"
      WHEN 2 THEN "C組"
      WHEN 3 THEN "D組"
      WHEN 4 THEN "E組"
      ELSE NULL END AS class,
    CASE MOD(CAST((RAND()*10) AS INT64), 2)
      WHEN 0 THEN "man"
      WHEN 1 THEN "woman"
      ELSE NULL END AS sex,
    CAST((RAND()*100) AS INT64) AS score
  FROM
    UNNEST(GENERATE_ARRAY(1, 100)) AS num
;

test.test_score

student_id class sex score
000001 C 組 woman 94
000002 B 組 man 12
000003 A 組 woman 79
000004 E 組 woman 48
000005 B 組 man 77
000006 C 組 woman 71
000007 B 組 man 65
000008 D 組 man 19
000009 A 組 man 48
000010 E 組 woman 91
000011 A 組 woman 8
000012 D 組 man 75
000013 C 組 woman 44
000014 C 組 man 96
... ... ... ...

sex 列を manwoman に分けて、それぞれ class 毎の平均点を表示させます。

以下は CASE 式を用いた例です。AVG() 関数内で CASE 式を利用しています。CASE 式は SELECT 句以外にも、WHERE 句や GROUP BY 句、HAVING 句にも書くことができ、本投稿内ではそのような使い方が頻出します。

SELECT
  class,
  ROUND(AVG(CASE WHEN sex = "man" THEN score ELSE NULL END), 2) AS men,
  ROUND(AVG(CASE WHEN sex = "woman" THEN score ELSE NULL END), 2) AS women
FROM test.test_score
GROUP BY class
ORDER BY class;
class men women
A 組 49.0 63.62
B 組 50.36 39.3
C 組 49.15 69.08
D 組 47.13 55.14
E 組 51.33 56.0

以下は、男子生徒と女子生徒のクラス毎平均点を求めるテーブルをサブクエリでそれぞれ作成し、それを内部結合させて表示させたものです。

SELECT
  men_avg.class AS class,
  men,
  women
FROM (
  (SELECT class, ROUND(AVG(score), 2) AS men FROM test.test_score WHERE sex = "man" GROUP BY class) AS men_avg
  INNER JOIN
  (SELECT class, ROUND(AVG(score), 2) AS women FROM test.test_score WHERE sex = "woman" GROUP BY class) AS women_avg
  ON men_avg.class = women_avg.class
)
ORDER BY class;
class men women
A 組 49.0 63.62
B 組 50.36 39.3
C 組 49.15 69.08
D 組 47.13 55.14
E 組 51.33 56.0

以下は、AVG 関数をウィンドウ関数を利用して表示したものです。

SELECT
  DISTINCT class,
  ROUND(AVG(IF(sex = "man", score, NULL)) OVER (PARTITION BY class), 2) AS men,
  ROUND(AVG(IF(sex = "woman", score, NULL)) OVER (PARTITION BY class), 2) AS women
FROM test.test_score
ORDER BY class;
class men women
A 組 49.0 63.62
B 組 50.36 39.3
C 組 49.15 69.08
D 組 47.13 55.14
E 組 51.33 56.0
  • 参考
    • 達人に学ぶ SQL 徹底指南書 - 1.CASE 式のすすめ

行から列(外部結合・スカラサブクエリ)

テスト用のテーブルを用意。

CREATE OR REPLACE TABLE test.testdata AS (
  SELECT "夜空" AS name, "A" AS class UNION ALL
  SELECT "夜空" AS name, "B" AS class UNION ALL
  SELECT "赤井" AS name, "B" AS class UNION ALL
  SELECT "白上" AS name, "A" AS class UNION ALL
  SELECT "白上" AS name, "C" AS class UNION ALL
  SELECT "夏色" AS name, "B" AS class UNION ALL
  SELECT "夏色" AS name, "C" AS class
);

test.testdata

name class
夏色 B
夏色 C
夜空 B
夜空 A
白上 A
白上 C
赤井 B

class 列を、それぞれ A 列と B 列と C 列に分けて、値の有無のチェック印を登録します。

以下は、各列毎にスカラサブクエリを実行したものです。

SELECT
  DISTINCT name,
  (SELECT "" FROM test.testdata AS T1 WHERE class = "A" AND T1.name = T0.name) AS A,
  (SELECT "" FROM test.testdata AS T2 WHERE class = "B" AND T2.name = T0.name) AS B,
  (SELECT "" FROM test.testdata AS T3 WHERE class = "C" AND T3.name = T0.name) AS C
FROM test.testdata AS T0;
name A B C
夏色 (NULL)
夜空 (NULL)
白上 (NULL)
赤井 (NULL) (NULL)

値 A をもつテーブル、値 B をもつテーブル、値 C をもつテーブルをそれぞれ用意し、外部結合して表示したものです。

SELECT
  T0.name AS name,
  CASE WHEN T1.name IS NOT NULL THEN "" ELSE NULL END AS A,
  CASE WHEN T2.name IS NOT NULL THEN "" ELSE NULL END AS B,
  CASE WHEN T3.name IS NOT NULL THEN "" ELSE NULL END AS C
FROM
  (SELECT DISTINCT name FROM test.testdata) AS T0
LEFT OUTER JOIN
  (SELECT name FROM test.testdata WHERE class = "A") AS T1
  ON T1.name = T0.name
LEFT OUTER JOIN
  (SELECT name FROM test.testdata WHERE class = "B") AS T2
  ON T2.name = T0.name
LEFT OUTER JOIN
  (SELECT name FROM test.testdata WHERE class = "C") AS T3
  ON T3.name = T0.name
;
name A B C
夏色 (NULL)
夜空 (NULL)
白上 (NULL)
赤井 (NULL) (NULL)

列から行

テスト用のテーブルを用意。

CREATE TABLE IF NOT EXISTS test.favorites (
  name STRING,
  item_1 STRING,
  item_2 STRING,
  item_3 STRING
);

INSERT INTO test.favorites
VALUES
  ("夜空", "りんご", NULL, NULL),
  ("赤井", "みかん", "きうい", NULL),
  ("白上", "ばなな", "みかん", "りんご"),
  ("夏色", NULL, NULL, NULL)
;

test.favorites

name item_1 item_2 item_3
夏色 (NULL) (NULL) (NULL)
夜空 りんご (NULL) (NULL)
白上 ばなな みかん りんご
赤井 みかん きうい (NULL)

item_1 列と item_2 列と item_3 列を、1 つの列にして表示します。

下記 SQL は、各 item 列毎のテーブルを用意して、それらテーブルを和集合して表示したものです。ただしこの SQL では、すべての item 列が NULL である行(テストデータは「夏色」にあたるもの)が表示されないことです。

WITH testdata AS (
  SELECT name AS name, item_1 AS item FROM test.favorites where item_1 IS NOT NULL
  UNION ALL
  SELECT name AS name, item_2 AS item FROM test.favorites where item_2 IS NOT NULL
  UNION ALL
  SELECT name AS name, item_3 AS item FROM test.favorites where item_3 IS NOT NULL
)
SELECT
  name,
  item
FROM testdata
ORDER BY name;
name item
夜空 りんご
白上 ばなな
白上 りんご
白上 みかん
赤井 みかん
赤井 きうい

「夏色」を NULL として表示する SQL は下記となります。各 item 列を和集合することは同じですが、その和集合したテーブルにオリジナルのテーブルを外部結合したものとなります。オリジナルのテーブル列を、マスター列のように利用したイメージです。

WITH testdata AS (
  SELECT name AS name, item_1 AS item FROM test.favorites WHERE item_1 IS NOT NULL
  UNION ALL
  SELECT name AS name, item_2 AS item FROM test.favorites WHERE item_2 IS NOT NULL
  UNION ALL
  SELECT name AS name, item_3 AS item FROM test.favorites WHERE item_3 IS NOT NULL
)
SELECT
  T0.name AS name,
  T1.item AS item
FROM
  test.favorites AS T0
LEFT OUTER JOIN
  testdata AS T1
  ON T0.name = T1.name
ORDER BY name;
name item
夏色 (NULL)
夜空 りんご
白上 りんご
白上 みかん
白上 ばなな
赤井 きうい
赤井 みかん
  • 参考
    • 達人に学ぶ SQL 徹底指南書 - 8.外部結合の使い方

入れ子の表

テスト用のテーブルを用意。

CREATE OR REPLACE TABLE test.m_gen AS (
  SELECT "1" AS gen_id, "一期生" AS gen UNION ALL
  SELECT "2" AS gen_id, "二期生" AS gen UNION ALL
  SELECT "3" AS gen_id, "三期生" AS gen
);

CREATE OR REPLACE TABLE test.m_blood AS (
  SELECT "1" AS blood_id, "A型" AS blood UNION ALL
  SELECT "2" AS blood_id, "B型" AS blood UNION ALL
  SELECT "3" AS blood_id, "O型" AS blood UNION ALL
  SELECT "4" AS blood_id, "AB型" AS blood
);

CREATE OR REPLACE TABLE test.members AS (
  SELECT "夜空メル" AS name, "1" AS gen_id, "4" AS blood_id, 4 AS dresses UNION ALL
  SELECT "赤井はあと" AS name, "1" AS gen_id, "1" AS blood_id, 5 AS dresses UNION ALL
  SELECT "白上フブキ" AS name, "1" AS gen_id, "1" AS blood_id, 5 AS dresses UNION ALL
  SELECT "夏色まつり" AS name, "1" AS gen_id, "3" AS blood_id, 6 AS dresses UNION ALL
  SELECT "兎田ぺこら" AS name, "3" AS gen_id, "2" AS blood_id, 4 AS dresses UNION ALL
  SELECT "不知火フレア" AS name, "3" AS gen_id, "2" AS blood_id, 4 AS dresses UNION ALL
  SELECT "白銀ノエル" AS name, "3" AS gen_id, "4" AS blood_id, 5 AS dresses UNION ALL
  SELECT "宝鐘マリン" AS name, "3" AS gen_id, "4" AS blood_id, 4 AS dresses
);

test.m_gen

gen_id gen
1 一期生
2 二期生
3 三期生

test.m_blood

blood_id blood
1 A 型
2 B 型
3 O 型
4 AB 型

test.members

name gen_id blood_id dresses
不知火フレア 3 2 4
兎田ぺこら 3 2 4
夏色まつり 1 3 6
夜空メル 1 4 4
宝鐘マリン 3 4 4
白上フブキ 1 1 5
白銀ノエル 3 4 5
赤井はあと 1 1 5

m_gen(3 rows) * m_blood(4 rows) のテーブルを、NULLとなる行も含めて作成します。

WITH master AS (
  SELECT
    gen_id,
    gen,
    blood_id,
    blood
  FROM test.m_gen CROSS JOIN test.m_blood
),
data AS (
  SELECT
    gen_id,
    blood_id,
    sum(dresses) AS sum_dresses
  FROM test.members
  GROUP BY gen_id, blood_id
)
SELECT
  master.gen,
  master.blood,
  data.sum_dresses
FROM master
LEFT OUTER JOIN data
ON master.gen_id = data.gen_id AND master.blood_id = data.blood_id
ORDER BY master.gen_id, master.blood_id;
gen blood sum_dresses
一期生 A 型 10
一期生 B 型 (NULL)
一期生 O 型 6
一期生 AB 型 4
二期生 A 型 (NULL)
二期生 B 型 (NULL)
二期生 O 型 (NULL)
二期生 AB 型 (NULL)
三期生 A 型 (NULL)
三期生 B 型 8
三期生 O 型 (NULL)
三期生 AB 型 9
  • 参考
    • 達人に学ぶ SQL 徹底指南書 - 8.外部結合の使い方

重複行の抽出

ROW_NUMBER() 関数を利用します。

WITH testdata AS (
  SELECT "001" AS id, "ときのそら" AS name, MD5(CAST(RAND()*10 AS STRING)) AS random UNION ALL
  SELECT "002" AS id, "ロボ子さん" AS name, MD5(CAST(RAND()*10 AS STRING)) AS random UNION ALL
  SELECT "003" AS id, "さくらみこ" AS name, MD5(CAST(RAND()*10 AS STRING)) AS random UNION ALL
  SELECT "003" AS id, "さくらみこ" AS name, MD5(CAST(RAND()*10 AS STRING)) AS random UNION ALL
  SELECT "004" AS id, "星街すいせい" AS name, MD5(CAST(RAND()*10 AS STRING)) AS random UNION ALL
  SELECT "005" AS id, "AZKi" AS name, MD5(CAST(RAND()*10 AS STRING)) AS random
)
SELECT
  * EXCEPT(row_num)
FROM (
  SELECT
    id,
    name,
    random,
    -- 重複とする列の条件で PARTITION BY する
    ROW_NUMBER() OVER (PARTITION BY id, name) AS row_num
  FROM
    testdata
)
-- 重複している場合は値が 2 以上となる
WHERE row_num >= 2
;
id name random
003 さくらみこ jGoIHqshLKNiMArEpqf2kw==

ウィンドウ関数による行間比較

テスト用のテーブルを用意。

CREATE OR REPLACE TABLE test.sales AS
  SELECT
    store, sale_date, sale
  FROM (
    SELECT "A" AS store, sale_date, CAST(RAND() * 100 AS INTEGER) AS sale
    FROM UNNEST(GENERATE_DATE_ARRAY("2022-11-01", "2022-11-03", INTERVAL 1 DAY)) AS sale_date
    UNION ALL
    SELECT "B" AS store, sale_date, CAST(RAND() * 100 AS INTEGER) AS sale
    FROM UNNEST(GENERATE_DATE_ARRAY("2022-11-01", "2022-11-03", INTERVAL 1 DAY)) AS sale_date
    UNION ALL
    SELECT "C" AS store, sale_date, CAST(RAND() * 100 AS INTEGER) AS sale
    FROM UNNEST(GENERATE_DATE_ARRAY("2022-11-01", "2022-11-03", INTERVAL 1 DAY)) AS sale_date
  )
;

test.sales

store sale_date sale
A 2022-11-03 86
A 2022-11-02 24
A 2022-11-01 24
B 2022-11-02 91
B 2022-11-03 51
B 2022-11-01 45
C 2022-11-01 4
C 2022-11-02 35
C 2022-11-03 1

ウィンドウ関数のフレーム句で、1 つ前のレコード(ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING)を指定し列を用意してあげて比較します。

SELECT
  store,
  sale_date,
  sale,
  pre_day_sale - sale AS pre_day_diff
FROM (
  SELECT
    store,
    sale_date,
    sale,
    MAX(sale) OVER (
      PARTITION BY store ORDER BY sale_date ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING
    ) AS pre_day_sale
  FROM test.sales
)
ORDER BY store, sale_date;
store sale_date sale pre_day_diff
A 2022-11-01 24 (NULL)
A 2022-11-02 24 0
A 2022-11-03 86 -62
B 2022-11-01 45 (NULL)
B 2022-11-02 91 -46
B 2022-11-03 51 40
C 2022-11-01 4 (NULL)
C 2022-11-02 35 -31
C 2022-11-03 1 34

重複期間の抽出

テスト用のテーブルを用意。

CREATE OR REPLACE TABLE test.reservations AS
  SELECT "さくらみこ" AS user, CAST("2022-10-01" AS DATE) AS start_date, CAST("2022-10-04" AS DATE) AS end_date UNION ALL
  SELECT "兎田ぺこら" AS user, CAST("2022-10-02" AS DATE) AS start_date, CAST("2022-10-05" AS DATE) AS end_date UNION ALL
  SELECT "潤羽るしあ" AS user, CAST("2022-10-06" AS DATE) AS start_date, CAST("2022-10-07" AS DATE) AS end_date UNION ALL
  SELECT "不知火フレア" AS user, CAST("2022-10-09" AS DATE) AS start_date, CAST("2022-10-18" AS DATE) AS end_date UNION ALL
  SELECT "白銀ノエル" AS user, CAST("2022-10-11" AS DATE) AS start_date, CAST("2022-10-13" AS DATE) AS end_date UNION ALL
  SELECT "宝鐘マリン" AS user, CAST("2022-10-17" AS DATE) AS start_date, CAST("2022-10-20" AS DATE) AS end_date UNION ALL
  SELECT "星街すいせい" AS user, CAST("2022-10-19" AS DATE) AS start_date, CAST("2022-10-22" AS DATE) AS end_date
;

test.reservations

user start_date end_date
さくらみこ 2022-10-01 2022-10-04
兎田ぺこら 2022-10-02 2022-10-05
潤羽るしあ 2022-10-06 2022-10-07
不知火フレア 2022-10-09 2022-10-18
白銀ノエル 2022-10-11 2022-10-13
宝鐘マリン 2022-10-17 2022-10-20
星街すいせい 2022-10-19 2022-10-22

副問合せ内の SQL で、ウィンドウ関数を利用して 1 つ手前のレコードを取得、その 1 つ手前の開始日と比較することで重複を検知します。ただしこの方法では、 1 つ手前のレコードとの比較しかできないため、多重度が 3 つ以上ある場合の検知はできません。

SELECT
  *
FROM (
  SELECT
    user,
    start_date,
    end_date,
    -- ウィンドウ関数で 1 つ手前のレコードを取得する
    MAX(user) OVER (
      ORDER BY start_date ROWS BETWEEN 1 FOLLOWING AND 1 FOLLOWING
    ) AS next_user,
    -- ウィンドウ関数で 1 つ手前のレコードを取得する
    MAX(start_date) OVER (
      ORDER BY start_date ROWS BETWEEN 1 FOLLOWING AND 1 FOLLOWING
    ) AS next_start_date
  FROM
    test.reservations
)
-- ウィンドウ関数で取得した値と比較する
WHERE next_start_date BETWEEN start_date AND end_date
ORDER BY start_date;
user start_date end_date next_user next_start_date
さくらみこ 2022-10-01 2022-10-04 兎田ぺこら 2022-10-02
不知火フレア 2022-10-09 2022-10-18 白銀ノエル 2022-10-11
宝鐘マリン 2022-10-17 2022-10-20 星街すいせい 2022-10-19

矩形の当たり判定のアルゴリズム を利用します。アルゴリズムの詳細については、下記の参考リンクより。この方法であれば多重度が 3 つ以上の期間も抽出できますが、重複のペアがそれぞれ 1 レコードずつ出力されるのですこし冗長です。

SELECT
  T1.user AS user_a,
  T1.start_date  AS user_a_start,
  T1.end_date AS user_a_end,
  T2.user AS user_b,
  T2.start_date AS user_b_start,
  T2.end_date AS user_b_end
FROM test.reservations AS T1
-- 比較元と比較先の情報を用意するために直積をつくる
CROSS JOIN test.reservations AS T2
-- 自分自身とは比較しないので除外する
WHERE T1.user <> T2.user
-- 矩形の当たり判定のアルゴリズム
AND NOT (T2.end_date <= T1.start_date OR T2.start_date >= T1.end_date)
ORDER BY T1.start_date, T2.start_date;
user_a user_a_start user_a_end user_b user_b_start user_b_end
さくらみこ 2022-10-01 2022-10-04 兎田ぺこら 2022-10-02 2022-10-05
兎田ぺこら 2022-10-02 2022-10-05 さくらみこ 2022-10-01 2022-10-04
不知火フレア 2022-10-09 2022-10-18 白銀ノエル 2022-10-11 2022-10-13
不知火フレア 2022-10-09 2022-10-18 宝鐘マリン 2022-10-17 2022-10-20
白銀ノエル 2022-10-11 2022-10-13 不知火フレア 2022-10-09 2022-10-18
宝鐘マリン 2022-10-17 2022-10-20 不知火フレア 2022-10-09 2022-10-18
宝鐘マリン 2022-10-17 2022-10-20 星街すいせい 2022-10-19 2022-10-22
星街すいせい 2022-10-19 2022-10-22 宝鐘マリン 2022-10-17 2022-10-20

部分的不一致な行の抽出

testdata のデータは genlive_date の列がペアとなるテーブルである、とします。その場合、夏色まつりの live_date の値は間違って登録されているため、どのように検知するか。

内部結合の自己結合をする。ペアとなる一方の列を結合する列として利用し、ペアのもう一方の列の値を比較することで、登録間違いを検知できます。

WITH testdata AS (
  SELECT "白上フブキ" AS name, 1 AS gen, "2021-05-28" AS live_date UNION ALL
  SELECT "赤井はあと" AS name, 1 AS gen, "2021-05-28" AS live_date UNION ALL
  SELECT "夏色まつり" AS name, 1 AS gen, "2021-06-29" AS live_date UNION ALL
  SELECT "兎田ぺこら" AS name, 3 AS gen, "2021-11-25" AS live_date UNION ALL
  SELECT "宝鐘マリン" AS name, 3 AS gen, "2021-11-25" AS live_date
)
SELECT
  DISTINCT A.gen,
  A.live_date
FROM testdata AS A
INNER JOIN testdata AS B
ON A.gen = B.gen
WHERE A.live_date <> B.live_date
ORDER BY A.gen;
gen live_date
1 2021-05-28
1 2021-06-29

内部結合した状態のテーブルは以下となっています(まつりちゃんの分だけ抜粋)。

name gen live_date name_1 gen_1 live_date_1
夏色まつり 1 2021-06-29 白上フブキ 1 2021-05-28
夏色まつり 1 2021-06-29 赤井はあと 1 2021-05-28
夏色まつり 1 2021-06-29 夏色まつり 1 2021-06-29
  • 参考
    • 達人に学ぶ SQL 徹底指南書 - 3.自己結合の使い方

欠落の判定

テスト用のテーブルを用意。2022 年 10 月 3 日から 2022 年 11 月 2 日までのカレンダーテーブルを作成して、2022 年 10 月 10 日と 2022 年 10 月 25 日のレコードを削除しておきます。

CREATE OR REPLACE TABLE test.tmp_calendar AS
  SELECT
    tmp_date
  FROM
    UNNEST(GENERATE_DATE_ARRAY("2022-10-03", "2022-11-02", INTERVAL 1 DAY)) AS tmp_date;

DELETE FROM test.tmp_calendar
WHERE tmp_date IN ("2022-10-10", "2022-10-25");
tmp_date
2022-10-03
2022-10-04
2022-10-05
2022-10-06
2022-10-07
2022-10-08
2022-10-09
2022-10-11
2022-10-12
2022-10-13
2022-10-14
2022-10-15
2022-10-16
2022-10-17
2022-10-18
2022-10-19
2022-10-20
2022-10-21
2022-10-22
2022-10-23
2022-10-24
2022-10-26
2022-10-27
2022-10-28
2022-10-29
2022-10-30
2022-10-31
2022-11-01
2022-11-02

CASE 式内で比較期間の日数を取得して、テーブル内のレコード数と比較します。

SELECT
  CASE COUNT(tmp_date)
    WHEN (SELECT COUNT(t) FROM UNNEST(GENERATE_DATE_ARRAY("2022-10-03", "2022-11-02", INTERVAL 1 DAY)) AS t) THEN "問題なし"
    ELSE "欠落あり" END AS gap,
FROM
  test.tmp_calendar
;
gap
欠落あり

BigQuery での HAVING 句の制限

書籍で紹介されている SQL は、HAVING 句で特性関数を利用した比較をするものでした。しかしながら、BigQuery ではそのまま実行することはできませんでした。書籍で紹介されていた SQL は GROUP BY 句をもたないものとなり、 そういった SQL に対して HAVING 句を実行すると、以下のエラーが表示されます。

The HAVING clause only allows aggregation if GROUP BY or SELECT list aggregation is present at [x:x]

HAVING 句を利用したいのであれば集合をつくれ、という指摘ですね。

HAVING 句は、GROUP BY または集計で生成された結果をフィルタリングします。GROUP BY または集計がクエリに存在する必要があります。

HAVING 句

そこで、以下のように全レコードに一意の値を持たせ、その値を GROUP BY することで、特性関数を利用した SQL を実行できるようにしています。

WITH _tmp_calendar AS (
  SELECT
    *,
    -- 全レコードで一意の値を付与する
    "1" AS tmp
  FROM test.tmp_calendar
)
SELECT
  "欠落あり" AS gap
FROM
  _tmp_calendar
GROUP BY tmp
HAVING count(tmp_date) <> DATE_DIFF(DATE '2022-10-03', DATE '2022-11-02', DAY)
;
gap
欠落あり
  • 参考
    • 達人に学ぶ SQL 徹底指南書 - 6.HAVING 句の力

条件を満たす集合を探す(HAVING 句と特性関数)

テスト用のテーブルを用意。

CREATE OR REPLACE TABLE test.test_score AS
  SELECT
    LPAD(CAST(num AS STRING), 6, "0") AS student_id,
    CASE MOD(CAST((RAND()*10) AS INT64), 5)
      WHEN 0 THEN "A組"
      WHEN 1 THEN "B組"
      WHEN 2 THEN "C組"
      WHEN 3 THEN "D組"
      WHEN 4 THEN "E組"
      ELSE NULL END AS class,
    CASE MOD(CAST((RAND()*10) AS INT64), 2)
      WHEN 0 THEN "man"
      WHEN 1 THEN "woman"
      ELSE NULL END AS sex,
    CAST((RAND()*100) AS INT64) AS score
  FROM
    UNNEST(GENERATE_ARRAY(1, 100)) AS num
;

test.test_score

student_id class sex score
000001 E 組 man 95
000002 E 組 man 43
000003 B 組 man 75
000004 B 組 woman 77
000005 D 組 man 19
000006 C 組 man 37
000007 B 組 man 47
000008 C 組 man 5
000009 E 組 woman 64
000010 E 組 man 74
000011 C 組 man 61
000012 A 組 man 85
... ... ... ...

特性関数とは下記。集合の条件を判断する関数、ということでしょうか。

(1) 確率論で確率変数の分布の特性を示す関数で,確率密度のフーリエ変換をいう。 (2) 一般に集合 X の部分集合 A に対し,A を指定するために x∈A なら 1,x∉A なら 0 として,x が A に入るかどうかを判断すればよいが,これを X 上の関数と考えて A の特性関数という。これについては特徴関数,定義関数などの用語も使われる。

コトバンク - 特性関数

この「集合の条件を判断する関数」を、HAVING 句で指定することで、GROUP BY で揃えたものより該当する集合を探す方法です。

以下の SQL は、score 50 点以上を取得した生徒が、半数を超える class を検索。

SELECT
  class
FROM test.test_score
GROUP BY class
HAVING
  COUNT(score) * 0.50 <= SUM(CASE WHEN score >= 50 THEN 1 ELSE 0 END)
ORDER BY class;
class
A 組
E 組

男子生徒の平均点が、女子生徒の平均点を上回る class を検索。

SELECT
  class
FROM
  test.test_score
GROUP BY class
HAVING
  AVG(CASE WHEN sex = "man" THEN score ELSE NULL END) > AVG(CASE WHEN sex = "woman" THEN score ELSE NULL END)
ORDER BY class;
class
A 組
E 組
  • 参考
    • 達人に学ぶ SQL 徹底指南書 - 6.Having 句の力
    • SQL パズル - 11.作業依頼

存在量化子、全称量化子

存在量化子全称量化子 の定義は下記です。

存在量化

・ 条件を満たすものが存在することを表す

・ 条件を満たすような~は存在する、の形式的な表現

全称量化子

・ 条件を満たすもの全てを表す

・ 全ての~は条件~を満たす、の形式的な表現

量化子 Quantifier

テスト用のテーブルを用意。

CREATE OR REPLACE TABLE test.subject_score (
  student_id STRING,
  subject STRING,
  score INTEGER
);

INSERT INTO test.subject_score VALUES
  ("001", "国語", 71),
  ("001", "英語", 43),
  ("001", "数学", 68),
  ("002", "国語", 35),
  ("002", "英語", 89),
  ("003", "国語", 55),
  ("003", "数学", 68),
  ("004", "国語", 62),
  ("004", "英語", 93)
;
student_id subject score
001 国語 71
001 数学 68
001 英語 43
002 国語 35
002 英語 89
003 国語 55
003 数学 68
004 国語 62
004 英語 93

存在量化

EXISTS 句は、存在量化子を実装したものとなります。

以下は、subject に拘らず score 80 点以上を取得した生徒を求める、素直な EXISTS 句です。

SELECT
  DISTINCT student_id,
FROM
  test.subject_score AS T1
WHERE EXISTS (
  SELECT 1
  FROM test.subject_score AS T2
  WHERE T1.student_id = T2.student_id
  AND score >= 80
)
ORDER BY student_id;
student_id
002
004

全称量化子

全称量化子は、NOT EXISTS 句を利用した、二重否定の副問合せで実現できます。

「受験した教科すべてが 50 点以上である生徒」を探したい場合、「『50 点未満の教科がない生徒』以外の生徒」を探す NOT EXISTS 句を利用します。

SELECT
  DISTINCT student_id
FROM test.subject_score AS T1
WHERE NOT EXISTS (
  SELECT 1
  FROM test.subject_score AS T2
  WHERE T1.student_id = T2.student_id
  AND score < 50
);
student_id
003
004

全称量化子は、HAVING 句と特性関数を利用しても実現できます。特性関数で、50 点未満の行数を数えて 0 件である場合は、(当たり前ですが)全教科 50 点以上であると判断できます。

SELECT
  student_id
FROM test.subject_score
GROUP BY student_id
HAVING 0 = SUM(CASE WHEN score < 50 THEN 1 ELSE 0 END)
ORDER BY student_id;
student_id
003
004

下記は、下記の条件を満たす生徒を検索する SQL です。

  • 国語は 40 点以上
  • 英語は 40 点以上
SELECT
  student_id
FROM test.subject_score AS T1
-- 対象とするレコードを、国語と英語のレコードに限定する
WHERE subject IN ("国語", "英語")
-- 国語と英語それぞれが 40 点未満でない生徒を検索する
AND NOT EXISTS (
  SELECT 1 FROM test.subject_score AS T2
  WHERE T1.student_id = T2.student_id
  AND 1 = CASE WHEN subject = "国語" AND score < 40 THEN 1
              WHEN subject = "英語" AND score < 40 THEN 1
              ELSE 0 END
)
GROUP BY student_id
-- 2 教科試験していることを確認するため、集合内のレコード数を数えてあげる
HAVING count(student_id) >= 2
ORDER BY student_id;
student_id
001
004

これも HAVING 句と特性関数で表現できます。

SELECT
  student_id
FROM test.subject_score
WHERE subject IN ("国語", "英語")
GROUP BY student_id
HAVING 2 = SUM(CASE WHEN subject = "国語" AND score >= 40 THEN 1
                    WHEN subject = "英語" AND score >= 40 THEN 1
                    ELSE 0 END
)
ORDER BY student_id;
student_id
001
004
  • 参考
    • 達人に学ぶ SQL 徹底指南書 - 5.EXISTS 述語の使い方

順列、組み合わせ

テスト用のテーブルを用意。

CREATE OR REPLACE TABLE test.holo_0 (id  STRING, name STRING);

INSERT INTO test.holo_0
VALUES
  ("001", "ときのそら"),
  ("002", "ロボ子さん"),
  ("003", "さくらみこ"),
  ("004", "星街すいせい"),
  ("005", "AZKi")
;

test.holo_0

id name
001 ときのそら
002 ロボ子さん
003 さくらみこ
004 星街すいせい
005 AZKi

順列

取り出す要素の数だけ、交差結合の自己結合をします。そのままでは {"ときのそら", "ときのそら", "ときのそら"} といった重複順列を許すことになるので、WHERE 句で重複を除外してあげます。

SELECT
  A.name AS p_1,
  B.name AS p_2,
  C.name AS p_3
FROM test.holo_0 AS A
CROSS JOIN test.holo_0 AS B
CROSS JOIN test.holo_0 AS C
WHERE
  B.name NOT IN (A.name)
AND
  C.name NOT IN (A.name, B.name)
ORDER BY p_1;
p_1 p_2 p_3
AZKi ときのそら 星街すいせい
AZKi ときのそら さくらみこ
AZKi ときのそら ロボ子さん
AZKi 星街すいせい ときのそら
AZKi 星街すいせい さくらみこ
AZKi 星街すいせい ロボ子さん
AZKi ロボ子さん 星街すいせい
AZKi ロボ子さん ときのそら
AZKi ロボ子さん さくらみこ
AZKi さくらみこ 星街すいせい
AZKi さくらみこ ときのそら
AZKi さくらみこ ロボ子さん
さくらみこ AZKi 星街すいせい
さくらみこ AZKi ときのそら
さくらみこ AZKi ロボ子さん
... ... ...
  • 参考
    • SQL パズル - 40.順列

組み合わせ

取り出す要素の数だけ、内部結合の自己結合をします。そのままでは重複を許してしまうため、結合条件で非等値結合を利用します。非等値結合を利用することで、辞書順に比較してくれるため、既に登場している値を除外できるようになります。

SELECT
  A.name AS c_1,
  B.name AS c_2,
  C.name AS c_3
FROM test.holo_0 AS A
INNER JOIN test.holo_0 AS B ON A.name > B.name
INNER JOIN test.holo_0 AS C ON B.name > C.name
ORDER BY c_1;
c_1 c_2 c_3
ときのそら さくらみこ AZKi
ロボ子さん ときのそら AZKi
ロボ子さん ときのそら さくらみこ
ロボ子さん さくらみこ AZKi
星街すいせい さくらみこ AZKi
星街すいせい ロボ子さん ときのそら
星街すいせい ロボ子さん AZKi
星街すいせい ロボ子さん さくらみこ
星街すいせい ときのそら AZKi
星街すいせい ときのそら さくらみこ
  • 参考
    • 達人に学ぶ SQL 徹底指南書 - 3.自己結合の使い方

順列から組み合わせを作成

順列テーブルから組み合わせテーブルを作成します。

-- 順列テーブルをつくる処理
WITH testdata AS (
  SELECT
    A.name AS p_1,
    B.name AS p_2,
    C.name AS p_3
  FROM test.holo_0 AS A
  CROSS JOIN test.holo_0 AS B
  CROSS JOIN test.holo_0 AS C
  WHERE
    B.name NOT IN (A.name)
  AND
    C.name NOT IN (A.name, B.name)
)
SELECT
  T1.p_1 AS c_1,
  T2.p_2 AS c_2,
  T3.p_3 AS c_3
FROM testdata AS T1
INNER JOIN testdata AS T2 ON T1.p_1 > T2.p_2
INNER JOIN testdata AS T3 ON T2.p_2 > T3.p_3
GROUP BY T1.p_1, T2.p_2, T3.p_3
ORDER BY c_1;
c_1 c_2 c_3
ときのそら さくらみこ AZKi
ロボ子さん ときのそら AZKi
ロボ子さん ときのそら さくらみこ
ロボ子さん さくらみこ AZKi
星街すいせい ロボ子さん AZKi
星街すいせい ロボ子さん さくらみこ
星街すいせい ロボ子さん ときのそら
星街すいせい さくらみこ AZKi
星街すいせい ときのそら AZKi
星街すいせい ときのそら さくらみこ
  • 参考
    • SQL パズル - 44.商品のペア

集合演算

テスト用のテーブルを用意。

CREATE OR REPLACE TABLE test.holo_1 (id  STRING, name STRING);
CREATE OR REPLACE TABLE test.holo_gamers (id  STRING, name STRING);

INSERT INTO test.holo_1
VALUES
  ("001", "夜空メル"),
  ("002", "アキ・ローゼンタール"),
  ("003", "赤井はあと"),
  ("004", "白上フブキ"),
  ("005", "夏色まつり")
;

INSERT INTO test.holo_gamers
VALUES
  ("004", "白上フブキ"),
  ("006", "大神ミオ"),
  ("007", "猫又おかゆ"),
  ("008", "戌神ころね")
;

test.holo_1

id name
001 夜空メル
002 アキ・ローゼンタール
003 赤井はあと
004 白上フブキ
005 夏色まつり

test.holo_gamers

id name
004 白上フブキ
006 大神ミオ
007 猫又おかゆ
008 戌神ころね

和集合

UNION DISTINCT を利用。

SELECT id, name FROM test.holo_1
UNION DISTINCT
SELECT id, name FROM test.holo_gamers
ORDER BY id;
id name
001 夜空メル
002 アキ・ローゼンタール
003 赤井はあと
004 白上フブキ
005 夏色まつり
006 大神ミオ
007 猫又おかゆ
008 戌神ころね

差集合

EXCEPT DISTINCT を利用。「A EXCEPT DISTINCT B」で、A から B を引いた差が求められます。

SELECT id, name FROM test.holo_1
EXCEPT DISTINCT
SELECT id, name FROM test.holo_gamers
ORDER BY id;
id name
001 夜空メル
002 アキ・ローゼンタール
003 赤井はあと
005 夏色まつり

共通集合

INTERSECT DISTINCT を利用。

SELECT id, name FROM test.holo_1
INTERSECT DISTINCT
SELECT id, name FROM test.holo_gamers
ORDER BY id;
id name
004 白上フブキ

直積集合

CROSS JOIN を利用。

SELECT
  holo_1.id AS id_1,
  holo_1.name AS name_1,
  holo_gamers.id AS id_gamers,
  holo_gamers.name AS name_gamers
FROM test.holo_1 AS holo_1
CROSS JOIN test.holo_gamers AS holo_gamers
ORDER BY id_1, id_gamers;
id_1 name_1 id_gamers name_gamers
001 夜空メル 004 白上フブキ
001 夜空メル 006 大神ミオ
001 夜空メル 007 猫又おかゆ
001 夜空メル 008 戌神ころね
002 アキ・ローゼンタール 004 白上フブキ
002 アキ・ローゼンタール 006 大神ミオ
002 アキ・ローゼンタール 007 猫又おかゆ
002 アキ・ローゼンタール 008 戌神ころね
003 赤井はあと 004 白上フブキ
003 赤井はあと 006 大神ミオ
003 赤井はあと 007 猫又おかゆ
003 赤井はあと 008 戌神ころね
004 白上フブキ 004 白上フブキ
004 白上フブキ 006 大神ミオ
004 白上フブキ 007 猫又おかゆ
004 白上フブキ 008 戌神ころね
005 夏色まつり 004 白上フブキ
005 夏色まつり 006 大神ミオ
005 夏色まつり 007 猫又おかゆ
005 夏色まつり 008 戌神ころね

排他的和集合

比較する両テーブルの完全外部結合をつくり、両テーブルどちらかの列が NULL である行のみ取得します。

SELECT
  COALESCE(A.id, B.id) AS id,
  COALESCE(A.name, B.name) AS name
FROM test.holo_1 AS A
FULL OUTER JOIN test.holo_gamers AS B
ON A.id = B.id
WHERE A.id IS NULL
OR B.id IS NULL
ORDER BY id;
id name
001 夜空メル
002 アキ・ローゼンタール
003 赤井はあと
005 夏色まつり
006 大神ミオ
007 猫又おかゆ
008 戌神ころね

完全外部結合のみした状態は下記となります。ここから NULL を持たない行を除いてあげています。

A.id A.name B.id B.name
001 夜空メル (NULL) (NULL)
002 アキ・ローゼンタール (NULL) (NULL)
003 赤井はあと (NULL) (NULL)
004 白上フブキ 004 白上フブキ
005 夏色まつり (NULL) (NULL)
(NULL) (NULL) 006 大神ミオ
(NULL) (NULL) 007 猫又おかゆ
(NULL) (NULL) 008 戌神ころね
  • 参考
    • 達人に学ぶ SQL 徹底指南書 - 8.外部結合の使い方

関係演算

商演算(関係除算)

テスト用のテーブルを用意。

CREATE OR REPLACE TABLE test.arsenal (
  name STRING,
  position STRING
);

INSERT INTO test.arsenal VALUES
  ("Benjamin White", "RCB"),
  ("Benjamin White", "RFB"),
  ("William Saliba", "RCB"),
  ("William Saliba", "LCB"),
  ("Gabriel  Magalhaes", "LCB"),
  ("TOMIYASU Takehiro", "RFB"),
  ("TOMIYASU Takehiro", "RCB"),
  ("TOMIYASU Takehiro", "LCB"),
  ("TOMIYASU Takehiro", "LFB"),
  ("Kieran Tierney", "LFB"),
  ("Oleksandr Zinchenko", "LFB")
;

CREATE OR REPLACE TABLE test.requires (
  position STRING
);

INSERT INTO test.requires VALUES
  ("RCB"),
  ("RFB")
;

test.arsenal

name position
Benjamin White RCB
Benjamin White RFB
Gabriel Magalhaes LCB
Kieran Tierney LFB
Oleksandr Zinchenko LFB
TOMIYASU Takehiro LCB
TOMIYASU Takehiro LFB
TOMIYASU Takehiro RCB
TOMIYASU Takehiro RFB
William Saliba LCB
William Saliba RCB

test.requires

position
RCB
RFB

test.arsenal に登録されている選手から、 test.requires に登録された position をできる選手を探す方法を考えます。

まず両テーブを内部結合することで、requires の値をもつ選手のみ抽出します。その後、HAVIGN 句の特性関数で requires と同レコード数の選手に絞り込みます。ただし、この方法では requires に登録された値以外をもったレコード(テストデータを踏まえると冨安のこと)も検知されることになります。こういった関係除算を 剰余を持った関係除算(division with a remainder) と呼ぶとのこと。

SELECT
  name
FROM test.arsenal AS arsenal
-- この内部結合の処理で、requires を満たす選手のみに絞られる
INNER JOIN test.requires AS requires
ON arsenal.position = requires.position
GROUP BY arsenal.name
-- 絞られたテーブルと requires テーブルのレコード数を比較する特性関数
HAVING COUNT(arsenal.position) = (SELECT COUNT(position) FROM test.requires)
ORDER BY name;
name
Benjamin White
TOMIYASU Takehiro

内部結合のみした状態は下記となります。サリバも取得されているため、HAVING 句の特性関数でさらに絞ってあげます。

name position position_1
Benjamin White RCB RCB
Benjamin White RFB RFB
TOMIYASU Takehiro RCB RCB
TOMIYASU Takehiro RFB RFB
William Saliba RCB RCB
  • 参考
    • 達人に学ぶ SQL 徹底指南書 - 6.HAVING 句のちから
    • SQL パズル - 21.飛行機と飛行士

余りを作らない除算の方法です。arsenal テーブル側を左側にして両テーブを左外部結合すると、結合できなかった require テーブル側の値が NULL に変換されるため、その情報を利用して特性関数を実行します。これを 厳密な関係除算(exact relational division) と呼ぶとのことです。

SELECT
  name
FROM test.arsenal AS arsenal
-- 外部結合する
LEFT OUTER JOIN test.requires AS requires
ON arsenal.position = requires.position
GROUP BY arsenal.name
-- test.requires とレコード数が一致するものに絞り込む
HAVING COUNT(arsenal.position) = (SELECT COUNT(position) FROM test.requires)
-- NULL は COUNT されないため、結合前後で requires レコードの数が異なるものは除外できる
AND COUNT(requires.position) = (SELECT COUNT(position) FROM test.requires)
ORDER BY name;
name
Benjamin White

外部結合のみした状態は下記となります。requires テーブルにない値は NULL として結合されるため、絞り込むための特性関数を実行してあげます。

name position position_1
Benjamin White RCB RCB
Benjamin White RFB RFB
Gabriel Magalhaes LCB (NULL)
Kieran Tierney LFB (NULL)
Oleksandr Zinchenko LFB (NULL)
TOMIYASU Takehiro LCB (NULL)
TOMIYASU Takehiro LFB (NULL)
TOMIYASU Takehiro RCB RCB
TOMIYASU Takehiro RFB RFB
William Saliba LCB (NULL)
William Saliba RCB RCB
  • 参考
    • 達人に学ぶ SQL 徹底指南書 - 6.HAVING 句のちから
    • SQL パズル - 21.飛行機と飛行士

関係除算の SQL は、以下の 3 つの方法で表現できるそうです。

  1. NOT EXISTS を入れ子にする
  2. HAVING 句を使った一対一対応を利用する
  3. 割り算を引き算で表現する

それぞれ書籍にて「1」および「3」の参考 SQL が記載されていましたが、BigQuery では実行できませんでした。その際に出力されるエラー内容です。

Correlated subqueries that reference other tables are not supported unless they can be de-correlated, such as by transforming them into an efficient JOIN.

以下が BigQuery の相関サブクエリに関する記述です。マニュアルの文章を掴みきれず、これは動作を踏まえた上での憶測ですが、相関サブクエリを利用したサブクエリ内で、さらに結合処置のような事をしようとすると怒られるのではないかと考えています。

相関サブクエリは、そのサブクエリの外部から列を参照するサブクエリです。相関関係では、サブクエリの結果を再利用できません。

BigQuery - 相関サブクエリ