BigQueryのPipe syntax (パイプ構文) を使ってみたら可読性と使いやすさがちょびっと向上した

こんにちは!アドプラットフォーム事業部でアプリケーションエンジニアをやっている渡辺です。
今日はBigQueryに追加された新機能「Pipe syntax」が個人的にとても気に入ったのでその紹介をしたいと思います。
シャレオツな書き方をして気分を上げていきましょう!

はじめに

2025年02月現在、BigQueryのPipe syntaxはプレビュー版です。
現状そのままでは使うことができずフォームから申請が必要であり、また、GA版と異なりSLAの対象にもならないので注意が必要です。
利用する際には規約を確認の上利用しましょう!

申請フォームも記載されているドキュメントページはこちらです。
https://cloud.google.com/bigquery/docs/pipe-syntax-guide

|> パイプ構文とは

2024年10月、Pipe syntax(以降、パイプ構文)がプレビュー版としてリリースされました。
これは「パイプ演算子 |>」を用いた、従来のクエリと異なる記法でクエリの読み取り・書き込み・保守を容易にできるという触れ込みです。

|> 例

最もシンプルな通常のクエリを書いてみます。

SELECT * FROM `my-project.my-dataset.my-table`

これをPipe syntaxで書き直すとこのようになります。

FROM `my-project.my-dataset.my-table`
-- SELECT * FROM `my-project.my-dataset.my-table` と同義

様々な処理を入れたクエリはこのようになります。

FROM `my-project.my-dataset.access_log`
|> AGGREGATE COUNT(*) AS count GROUP BY page
|> WHERE count > 50
|> ORDER BY count DESC

パイプ構文はFROM句から始まり各パイプ演算子をパイプ|>で繋いでいきます。
それぞれのパイプ演算子は単体で自己完結し、出力結果として新たなテーブルを次のパイプ演算子に受け渡します。

ではこのパイプ構文を使うメリット・面白いところを見ていきましょう。

|> パイプ構文を実際に使ってみる

今回の検証のため、いくつかテーブルを作成しました。

Webページに広告リンクを載せていて、その広告のクリック・商品の成約まで追えるデータという想定です。

  • access_log: 各Webページのアクセスログ
  • click_log: Webページ上に表示された広告のクリックログ
  • conversion_log: 広告された商品が広告経由で買われた際のログ
  • campaign: 広告情報

|> すでにあるクエリを分解してみる

パイプ構文では1つのパイプ演算子で構成されている行を1行としたとき、どのタイミングでも出力ができます。
「パイプ構文とは」の例で出したクエリを使って確認してみます。

FROM `my-project.my-dataset.access_log`
|> AGGREGATE COUNT(*) AS count GROUP BY page
|> WHERE count > 50
|> ORDER BY count DESC

まず、これを実行するとこのように累計アクセス数50件以上のページがアクセス数降順で表示されます。

では少し削ってアクセス数の条件とソートをなくしてみます。

FROM `my-project.my-dataset.access_log`
|> AGGREGATE COUNT(*) AS count GROUP BY page
-- |> WHERE count > 50
-- |> ORDER BY count DESC

無事クエリは成功し、ソートがかかってない状態が表示されました。 (今回データの中身の関係上50件以下のレコードが上部に出ませんでした)

さらに削ることもできます。

FROM `my-project.my-dataset.access_log`
-- |> AGGREGATE COUNT(*) AS count GROUP BY page
-- |> WHERE count > 50
-- |> ORDER BY count DESC

このように各パイプ演算子は出力結果のテーブルを持っているため、一連のクエリの途中経過を確認することが容易です。
逆に言うと、1工程ずつクエリによる加工結果を確認しつつクエリを組み立てていくことができます。
次の節でこれが効果的に使えるユースケースを見てみます。

|> 要件に沿ってクエリを組み立ててみる

今回作ったテーブルについて、以下のような分析の要望が来たことを想定してみましょう。 (少々恣意的な内容なのは許してクダサイ)

クリックの情報について、

  • 直近半年のログで
  • 各ページのクリック数を出して
  • それぞれのページで各campaignが占める割合を出して
  • それらをページの総クリック数の降順で表示してほしい

これを図で表してみます。

順序立てて作ってみましょう。

|> 直近半年に絞る

FROM `my-project.my-dataset.click_log`
|> WHERE click_datetime > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 6 * 30 DAY)

これは問題ないですね。通常のクエリと同じようにWHERE句によって日付を範囲指定しています。

|> 各ページのクリック数を出す

FROM `my-project.my-dataset.click_log`
|> WHERE click_datetime > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 6 * 30 DAY)

|> EXTEND COUNT(*) OVER(PARTITION BY referrer) AS click_count

1行追記しました。分析関数でページごとのクリック数を算出します。
GROUP BYを使うことでも実現できますが、ここで行なってしまうとテーブルがグループ化され情報が失われてしまい次の処理で不便なので、
カラムを追加するEXTEND演算子を使いました。

|> それぞれのページで各campaignが占める割合を出す

FROM `my-project.my-dataset.click_log`
|> WHERE click_datetime > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 6 * 30 DAY)
|> EXTEND COUNT(*) OVER(PARTITION BY referrer) AS click_count

|> EXTEND COUNT(*) OVER(PARTITION BY referrer, campaign_id) / click_count AS campaign_percentage

先ほどと同様に分析関数を使ってページごとの各キャンペーンの数を出し、クリックに占める割合を出します。
このとき、前の行で新しく作ったclick_countカラムを分母として指定できます。
ここが通常の構文で書いた際と大きく違う点ではないでしょうか。
通常の構文ではSELECT句で算出して作り出した値を他のSELECT句内やWHERE句、GROUP BY句内で使うことができません。
クエリの評価順を考えればわかることですが直感的ではないですよね。
それがパイプ構文では上から出力結果を順々に渡されることによって問題なく活用できるようになっています。

また、算出したcampaign_percentageの桁数がレコードによってバラバラだと出力して気づいたので丸めましょう。

FROM `my-project.my-dataset.click_log`
|> WHERE click_datetime > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 6 * 30 DAY)
|> EXTEND COUNT(*) OVER(PARTITION BY referrer) AS click_count
|> EXTEND COUNT(*) OVER(PARTITION BY referrer, campaign_id) / click_count AS campaign_percentage

|> SET campaign_percentage = ROUND(campaign_percentage, 2)

一度算出した値を後からさらに加工することもできました。

|> ページの総クリック数の降順で表示する

FROM `my-project.my-dataset.click_log`
|> WHERE click_datetime > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 6 * 30 DAY)
|> EXTEND COUNT(*) OVER(PARTITION BY referrer) AS click_count
|> EXTEND COUNT(*) OVER(PARTITION BY referrer, campaign_id) / click_count AS campaign_percentage
|> SET campaign_percentage = ROUND(campaign_percentage, 2)

|> AGGREGATE GROUP BY referrer, campaign_id, click_count, campaign_percentage
|> ORDER BY click_count DESC

表示するカラムを追加するだけでまだグルーピングをしていなかったのでこのタイミングで行います。
click_countcampaign_percentageは最終出力で必要なので対象として入れておきます。
最後にORDER BY句でソートして目的のデータが作れました。

カラムの表示順が気になったのでSELECTで整えます。

FROM `my-project.my-dataset.click_log`
|> WHERE click_datetime > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 6 * 30 DAY)
|> EXTEND COUNT(*) OVER(PARTITION BY referrer) AS click_count
|> EXTEND COUNT(*) OVER(PARTITION BY referrer, campaign_id) / click_count AS campaign_percentage
|> SET campaign_percentage = ROUND(campaign_percentage, 2)
|> AGGREGATE GROUP BY referrer, campaign_id, click_count, campaign_percentage
|> ORDER BY click_count DESC, campaign_percentage DESC

|> SELECT referrer, click_count, campaign_id, campaign_percentage

|> キャンペーンをIDではなく名前で表示する

要望のフローにはありませんでしたがIDで表示されるよりキャンペーン名で出した方が親切なので追加しましょう。
LEFT JOINが使えます。

FROM `my-project.my-dataset.click_log`
|> WHERE click_datetime > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 6 * 30 DAY)
|> EXTEND COUNT(*) OVER(PARTITION BY referrer) AS click_count
|> EXTEND COUNT(*) OVER(PARTITION BY referrer, campaign_id) / click_count AS campaign_percentage
|> SET campaign_percentage = ROUND(campaign_percentage, 2)
|> AGGREGATE GROUP BY referrer, campaign_id, click_count, campaign_percentage
|> ORDER BY click_count DESC, campaign_percentage DESC
|> SELECT referrer, click_count, campaign_id, campaign_percentage

|> LEFT JOIN `my-project.my-dataset.campaign` USING(campaign_id)
|> SELECT referrer, click_count, campaign_name, campaign_percentage

テーブル結合を挟んだ結果、ソート順が崩れてしまいました。
ORDER BYの位置を最後に移動させます。また、途中のSELECTも不要なので消しておきます。

FROM `my-project.my-dataset.click_log`
|> WHERE click_datetime > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 6 * 30 DAY)
|> EXTEND COUNT(*) OVER(PARTITION BY referrer) AS click_count
|> EXTEND COUNT(*) OVER(PARTITION BY referrer, campaign_id) / click_count AS campaign_percentage
|> SET campaign_percentage = ROUND(campaign_percentage, 2)
|> AGGREGATE GROUP BY referrer, campaign_id, click_count, campaign_percentage
|> LEFT JOIN `my-project.my-dataset.campaign` USING(campaign_id)
|> SELECT referrer, click_count, campaign_name, campaign_percentage

|> ORDER BY click_count DESC, campaign_percentage DESC

綺麗に出すことができました。

このようにパイプ構文では加工したい順に1行1行書いていくことができるのでとても直感的です。
また、読む際も上から順々に読んでいけば処理順になっているので可読性も高いと思います。
通常のクエリではFROMを見てWHEREを見て一番上のSELECTを見て、と視点が大きく動くことがあるのでこの点は嬉しいですね。

また、今回記事中に各ステップの実行結果を入れました。
冒頭でもお話しした通りパイプ構文では各ステップで完結しているので全ての処理を書き終える前の途中段階でも出力をしてその時点での状態を確認できます。
ここが個人的には面白いと思う点です。これによって少なからずクエリを組む開発体験が上がると思っています。

最後に今回のクエリについてパイプ構文を使用したものとそうでないものをフローと並べてみました。 (普通のクエリもっと綺麗に書けたらすみません)
パイプ構文のスッキリさがより伝わるのではないでしょうか。

|> おわりに

今回はBigQueryのプレビュー版機能Pipe syntaxを使ってみました。
今まで触ってきたクエリとは書き方が変わるので多少の戸惑いはありますが学習コストはあまり高くなく、可読性を高められるので良いと思いました。

現在はまだプレビュー版なので今後さらに機能が追加されたりGA版になったりするのが楽しみです。
それではみなさん良いクエリライフを。

|> おまけ:検証:Dataformでの利用

Dataformでもパイプ構文は使用することができました。

GA版になったら既存のコードをリファクタしたいですね。