👻

BigQuery超入門③

2025/01/18に公開

はじめに

こちらは BigQuery 超入門 ② の続きになります。

学ぶこと

  • サブクエリについて
  • データ構造の変換
  • WITH とサブクエリの違いについて

サブクエリ(副問い合わせ)

仮想テーブルを作成する一つの方法。一つの SQL 文の中で別の SQL 文を利用する。

CTE とは違い、後続で使い回すようなものではないので上手に使い分けられるようにする。
(もし使い回すようなら何回も SELECT を書かないといけなくなるため)

WITH 句とサブクエリは次のよう記述する。
サブクエリは使い回すものではなく最初に CTE として定義する必要がないため記述量は減る。

WITH 句 Ver

WITH qty_avg AS (
    SELECT
        AVG(qty) AS total_avg_qty
    FROM
        project_id.dataset.products
)

SELECT
    *
FROM
    project_id.dataset.products AS p CROSS JOIN total_avg
WHERE
    qty > total_avg_qty

サブクエリ Ver

SELECT
    *
FROM
    project_id.dataset.products
WHERE
    qty > (SELECT AVG(qty) AS total_avg_qty FROM project_id.dataset.products)

サブクエリのイメージとしては、メインのクエリで必要とする部分的な結果を返すクエリ

一つの値を返す

サブクエリで一つの値を返す処理を確認する。
特に集計や絞り込みでよく使うことが多い。

新しいフィールドの作成
次のように都道府県毎の最低賃金を返すテーブルに対して「平均賃金」という新しいフィールドを作成することで実現できる。

SELECT
    *, (SELECT AVG(min_wage) FROM project_id.dataset.prefectures) AS min_wage
FROM
    project_id.dataset.prefectures

サブクエリの結果を演算で使う
次のように平均賃金を返すサブクエリの結果を使って各県の最低賃金との差を調べる。

SELECT
    *, min_wage - s(SELECT AVG(min_wage) FROM project_id.dataset.prefectures) AS min_wage
FROM
    project_id.dataset.prefectures

一列 n 行を返す

サブクエリで一列で複数行を返すようにし、それをメインクエリで利用することもある。業務でも使うことが多い。
クエリのコメントにもあるように実際にどのような値が入るかわからないような箇所に対して動的な表現をしたい時に使える。
例えば次のように IN 句を使う場合。

// 1. 平均賃金を取得
// SELECT AVG(min_wage) FROM project_id.dataset.prefectures

// 2. 最低賃金が平均賃金を超えている県を取得
// SELECT DISTINCT pref FROM project_id.dataset.prefectures WHERE min_wage > (SELECT AVG(min_wage) FROM project_id.dataset.prefectures)
// 2のイメージ(数字がクエリに置き換わっている)
// SELECT DISTINCT pref FROM project_id.dataset.prefectures WHERE min_wage > 995.33...

// 3. prefフィールドの県名がいずれかの「最低賃金が平均賃金を超えている県」に含まれているかどうかを絞り込む
// 3のイメージ(東京,千葉がクエリに置き換わっている)
// SELECT * FROM project_id.dataset.prefectures WHERE pref IN ("東京","千葉")

SELECT
    *
FROM
    project_id.dataset.prefectures
WHERE
    pref IN (SELECT DISTINCT pref FROM project_id.dataset.prefectures WHERE min_wage > (SELECT AVG(min_wage) FROM project_id.dataset.prefectures))

m 列 n 行を返す

サブクエリで通常のテーブルのように m 列 n 行の結果テーブルを返すパターン。
「集計の集計」をしたいときによく使われることが多い。
例えば次のように「最低賃金が 900 円を超えている都道府県数とそれらの平均最低賃金」を取得したい場合。
まずは絞り込みで「最低賃金が 900 円を超えている都道府県」を取得する。そして結果テーブルを元に「都道府県数」「平均最低賃金」を集計する。

// 1. 最低賃金が900円を超える都道府県を返す(m列n行を返すサブクエリ)
// SELECT * FROM project_id.dataset.prefectures WHERE r5_min_wage > 900
// 2. 1の結果テーブルを元に集計する

SELECT
    COUNT(pref) AS count_900_over_pref, AVG(r5_min_wage) AS avg_min_wage
FROM
    (SELECT * FROM project_id.dataset.prefectures WHERE r5_min_wage > 900)

WITH とサブクエリの違いについて

WITH 句とサブクエリの違いについてまとめてみる。

可読性

  • WITH 句: CTE 名を見るだけである程度どのような結果を返すのかが理解できる。一度定義すると以降は再利用しやすい
  • サブクエリ: 直接クエリ内に記述するためサブクエリが長くなるほど読みづらくなりどういう結果テーブルを返すのかを理解するのに時間がかかる。再利用するような場合は毎回同じクエリを書くことになり冗長になりやすい

パフォーマンス

  • WITH 句: クエリ内ではメモリに保存されるらしい。再利用する場合はメモリから呼び出すので、問い合わせしてデータを作成する必要がない
  • サブクエリ: 実行するたびに問い合わせしてデータ作成をするためクエリによっては時間がかかってしまう

データ構造の変換

データには「縦持ち」「横持ち」という持ち方が存在しているらしい。

縦持ちのテーブル

pref year min_wage
東京 r3 1013
東京 r4 1041
東京 r5 1072
大阪 r3 964
大阪 r4 992
大阪 r5 1023
福岡 r3 842
福岡 r4 870
福岡 r5 901

横持ちのテーブル

pref r3_min_wage r4_min_wage r5_min_wage
東京 1013 1041 1072
大阪 964 992 1023
福岡 842 870 901

縦持ちと横持ちの違い

データの見え方

横持ちのテーブルは各行が一つの項目を表し(最低賃金)、各列が項目の特性を表しているイメージ。
縦持ちテーブルの場合は各行が一つの事実を表す(東京都の令和3年の最低賃金)イメージ。

縦持ちテーブルの方が集計がしやすい

例えば平均の最低賃金を求める場合
縦持ちだと AVG を使って求めることができるが、横持ちの場合は (r3_min_wage+r4_min_wage+r5_min_wage)/3 のような計算をする必要がある。

一方で横持ちの場合は「令和4年の平均賃金に対する令和 5 年の伸び率」のような「フィールド同士の計算」が得意。
縦持ちでやろうとするとフィールド同士で比較するためにセルフジョインやサブクエリを使う必要がある。

データ追加時の挙動
例えば令和6年のデータが増えた時
縦持ちの場合はレコードが追加されるためテーブルが縦に伸びていくが、横持ちの場合はデータの追加とともにフィールドが横に伸びていく。

横持ちテーブルを縦持ちテーブルに変換する

order_id 毎で商品カテゴリ別の売り上げ金額を表している横持ちテーブルを縦持ちテーブルに変換してみる。

order_id fashion zakka food
123 18600 null 5800
124 null 2400 8800
125 6900 2900 11200
126 4200 3800 4500
127 null 9800 null
SELECT
    order_id, "fashion" AS product_category, fashion AS revenue
FROM
    project_id.dataset.sales

次のように fashion を縦持ちテーブルに変換できる。

order_id product_category fashion
123 fashion 18600
124 fashion null
125 fashion 6900
126 fashion 4200
127 fashion null

UNION ALL で繋げることで他の商品も含めて全てを縦持ちのテーブルに変換できる。

SELECT
    order_id, "fashion" AS product_category, fashion AS revenue
FROM
    project_id.dataset.sales
UNION ALL
    (
        SELECT
            order_id, "zakka" AS product_category, zakka AS revenue
        FROM
            project_id.dataset.sales
    )
UNION ALL
    (
        SELECT
            order_id, "food" AS product_category, food AS revenue
        FROM
            project_id.dataset.sales
    )
order_id product_category fashion
123 fashion 18600
124 fashion null
123 zakka null
124 zakka 2400
123 food 5800
124 food 8800

専用関数を使って横持ちテーブルを縦持ちテーブルに変換する

BigQuery がサポートしている UNPIVOT 関数を使って横持ちテーブルを縦持ちテーブルに変換してみる。
UNPIVOT 関数は横持ちから縦持ちに変換する専用関数になる。

// 1. revenue => 横持ちテーブルで指標となっていた値を格納する任意の列名を指定している
// 今回の場合は売り上げ金額が指標となっていたので金額が格納される
// 2. product_category => 指標を分けていた基準名(任意)
// 売り上げ金額を商品カテゴリで分けていたので product_category とする
// INでは縦持ちに変換したい値のカラム名を指定。元のテーブルのカラム名と完全一致する必要がある。

SELECT
    *
FROM
    project_id.dataset.sales UNPIVOT INCLUDE NULLS (revenue FOR product_category IN (fashion,zakka,food))
order_id product_category fashion
123 fashion 18600
123 zakka null
123 food 5800
124 fashion null
124 zakka 2400
124 food 8800

null の値を除外したい場合は次のようになる。

SELECT
    *
FROM
    project_id.dataset.sales UNPIVOT EXCLUDE NULLS (revenue FOR product_category IN (fashion,zakka,food))
order_id product_category fashion
123 fashion 18600
123 food 5800
124 zakka 2400
124 food 8800

縦持ちテーブルを横持ちテーブルに変換する

専用関数を使わずに変換してみる。

SELECT
    ad_id,
    IF(phase="before",bounce_rate,null) AS before_index,
    IF(phase="after",bounce_rate,null) AS after_index
FROM
    project_id.dataset.test

次のように非正規化された横持ちのテーブルが返ってくる。

ad_id before_index after_index
A 0.69 null
A 0.67 null
A null 0.63
A null 0.61

専用関数を使って縦持ちから横持ちに変換する

横持ちに変換する専用関数としてPIVOT 関数が存在する。

// 1. AVG(bounce_rate) => 横持ちにした時に指標となる値とその集計方法を指定する
// 2. phase => 指標を列に分ける値が格納されているカラム名を指定する
// 3. IN で指標を列に分ける基準の値を指定。元テーブルと一致する値じゃないとnullになってしまう

SELECT
    ad_id,
    before,
    after
FROM
    project_id.dataset.test PIVOT (AVG(bounce_rate) FOR phase IN ("before","after"))

専用関数を使わなかった場合と同じ結果が返ってくる。

ad_id before_index after_index
A 0.69 null
A 0.67 null
A null 0.63
A null 0.61

参考記事・書籍

BigQuery ではじめる SQL データ分析 GA4 & Search Console & Google フォーム対応
WITH 句かサブクエリか

GitHubで編集を提案

Discussion