エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

BigQueryでJSONをテーブルに自動変換できるようにしてみた

こちらはエムスリー Advent Calendar 2024の20日目の記事です。

デジスマチームの田口です。 デジスマ診療(以降デジスマ)はQRコードによるチェックインや自動後払い、オンライン診療など新しい医療体験を提供するサービスです。

www.youtube.com

ありがたいことにサービスは成長し続けており、これに伴ってKPIの深掘りや施策の効果検証のためのデータ分析もより積極的に行われるようになってきました。

デジスマチームではデータ分析基盤にBigQueryを利用しており、Amazon Auroraや各種ログデータをBigQueryに連携し、様々な分析をしています。 多種多様なデータを連携している都合上、データの中にはJSON形式のカラムのものもあり、この内容を検索や集計したいケースも出てきます。 分析する中でJSON形式のデータをテーブルに変換したくなったため、JSONのカラムをテーブルに変換する方法を考えてみました。

はじめに

デジスマでBigQueryに連携しているデータの中には、構造的なデータや配列を表現したいものをJSON形式で保存しているものもあります。 これはデータ保存の観点では柔軟ですが、それ故にSQLで分析する際は工夫が必要です。

サンプルデータ

本記事では、こちらの 1.7m users (504.3 MB) データをサンプルとして利用します。

Exploring the power of JSON: a real-life JSON file example collection

[
  {
    "id": 0,
    "name": "Elijah",
    "city": "Austin",
    "age": 78,
    "friends": [
      {
        "name": "Michelle",
        "hobbies": [
          "Watching Sports",
          "Reading",
          "Skiing & Snowboarding"
        ]
      },
      {
        "name": "Robert",
        "hobbies": [
          "Traveling",
          "Video Games"
        ]
      }
    ]
  },
  {
    "id": 1,
    "name": "Noah",
    "city": "Boston",
    "age": 97,
    "friends": [
      {
        "name": "Oliver",
        "hobbies": [
          "Watching Sports",
          "Skiing & Snowboarding",
          "Collecting"
        ]
      },
      ...
    ]
  },
  ...
]

サンプルのデータは上記のような構造になっています。 BigQueryには、上記データを1つずつ user_json カラムに格納した users テーブルを作成します。

カラム名 データ型 値の例
user_json JSON {"id":0,"name":"Elijah","city":"Austin","age":78,...}

JSON関数を用いた分析

BigQueryではJSON型をサポートしており、JSONを操作する関数も様々なものが提供されています。

例えば、年齢が50歳以上のユーザーを取得するには次のようなクエリになります。

SELECT
  JSON_VALUE(user_json, '$.id') AS id,
  JSON_VALUE(user_json, '$.name') AS name,
  JSON_VALUE(user_json, '$.age') AS age,
FROM
  test.users -- `test` データセットにテーブルを作成
WHERE
  CAST(JSON_VALUE(user_json, '$.age') AS int64 ) >= 50
ORDER BY CAST(id AS int64)

このクエリの結果(一部)は次のようになります。

JSON_VALUE関数を用いることで、JSONの値から特定のフィールドの値を抽出できます。 フィールドはJSONPath形式で指定します。

このようにJSON関数を用いることで、JSONのフィールドで分析できます。

JSONをテーブルに変換

JSON関数によってある程度JSONフィールドの値を利用した分析が可能になりますが、別のテーブルとjoinする場合など、辛いケースが出てきます。 そこで、JSONフィールドをフラットなテーブルで表現できないか考えます。

SELECT
  JSON_VALUE(user_json, '$.id') AS id,
  JSON_VALUE(user_json, '$.name') AS name,
  JSON_VALUE(user_json, '$.age') AS age,
  JSON_VALUE(user_json, '$. friends.name') AS friend_names,
  ...
FROM
  test.users

上記のようにフィールドを1つずつ指定することによってフラットな形式に変換することはできますが、フィールド数が多い場合や構造が複雑な場合は指定が手間になります。またJSONの構造が変わった時に変換のクエリも修正する必要があります。

そこで、JSONの実データからスキーマを自動的に抽出し、フラットなテーブルにするSQLを考えました。

-- 1. UDF: JSONキーと値を同時に取得
CREATE TEMP FUNCTION extract_keys_and_values(input STRING)
RETURNS ARRAY<STRUCT<key STRING, value STRING>>
LANGUAGE js AS """
  function flattenObj(obj, parent = '', res = []) {
    for (let key in obj) {
      const propName = parent ? parent + '.' + key : key;
      if (typeof obj[key] === 'object' && obj[key] !== null) {
        flattenObj(obj[key], propName, res);
      } else {
        res.push({ key: propName, value: String(obj[key]) });
      }
    }
    return res;
  }
  return flattenObj(JSON.parse(input));
""";

-- 2. 一時テーブルの作成: JSONをキーと値のペアに展開
CREATE TEMP TABLE temp_table AS
SELECT
  row_num,
  kv.key,
  kv.value
FROM
  (
    SELECT *,
    ROW_NUMBER() OVER () AS row_num,
    FROM test.users
  ),
  UNNEST(extract_keys_and_values(TO_JSON_STRING(user_json))) AS kv;

-- 3. ピボットクエリの動的生成
EXECUTE IMMEDIATE (
  SELECT FORMAT("""
    SELECT * EXCEPT(row_num)
    FROM temp_table
    PIVOT (
      ANY_VALUE(value) 
      FOR REPLACE(key, '.', '__') IN (%s)
    )
    ORDER BY CAST(id AS INT64)
  """, STRING_AGG(FORMAT('"%s"', REPLACE(key, '.', '__')), ','))
  FROM (
    SELECT DISTINCT key FROM temp_table
  )
);

このようなSQLを実行することによって、user_json を次のようなテーブルに変換できます。 このSQLについて詳しく見ていきます。

1. UDF: JSONキーと値を同時に取得

はじめに、JSONのキーと値のペアを出力する関数を作成します。 BigQueryではユーザー定義の関数を作成することができ、SQLまたはJavaScriptコードで処理を定義できます。 ここでは、JSON文字列を入力とし、キーと値のペアを出力します。ネストされたキーは parent.child 形式に変換します。また、値はすべてSTRING型で返却されます。

[
  { key: "id", value: "0" },
  { key: "name", value: "Elijah" },
  { key: "age", value: "78" },
  { key: "friends.0.name": "Michelle" },
  { key: "friends.0.hobbies.0": "Watching Sports" },
  ...
]

2. 一時テーブルの作成: JSONをキーと値のペアに展開

次に、この関数を利用してキーと値のペアに展開し、一時テーブルに保存します。 一時テーブルの構造は次のようになります。

row_num key value
1 id 0
1 name Elijah
1 age 78
1 friends.0.name Michelle
1 friends.0.hobbies.0 Watching Sports
...
2 id 1
2 name Noah
2 age 97
2 friends.0.name Oliver
2 friends.0.hobbies.0 Watching Sports

後で各ユーザー毎にグルーピングできるよう一意になる値を持っておく必要があります。今回は ROW_NUMBER 関数で行番号を付与するようにしました。

3. ピボットクエリの動的生成

最後に、2.で作成した一時テーブルについて、キー毎に列を作成するピボットテーブルを動的に生成します。 SELECT DISTINCT key FROM temp_table でJSONのキーの一覧を取得し、これをピボットの列として指定します。 また、EXECUTE IMMEDIATEを利用し、IN句の条件を動的に組み立てています。 REPLACE(key, '.', '__') は、テーブルの列名に . が使えないためアンダースコア __ に書き換えています。

このようなクエリを実行することで、最終的にJSONデータをフラット化したテーブルを出力することができます。

id name age friends__0__name friends__0__hobbies__0 ...
0 Elijah 78 Michelle Watching Sports ...
1 Noah 97 Oliver Watching Sports ...

おわりに

BigQueryのJSONカラムに対して、JSON関数で分析する方法や、フラットなテーブルとして出力する方法を紹介しました。

ここまで書いておいてになりますが、BigQueryでは公式でファイルからJSONデータを読み込む方法が提供されているため、データをBigQueryに連携する前に取得できるのであれば、このような機能を利用した方が吉です。 独自のクエリをメンテナンスする必要もなく、また今回のようなSQLはJSONカラムをスキャンするのにコストがかかるため、奥の手として使うのが良いでしょう。

We are hiring!!

エムスリーでは絶賛エンジニアを募集中です! デジスマ診療以外にも様々なプロダクトがありますので、ご興味ある方は是非カジュアル面談等ご応募ください!

jobs.m3.com