めもちょー

メモ帳代わりに使っています。

MySQLでJSONを要素とする配列を平坦化し集計する問題

目次

事例

下記のようにサーバーで発行されたログがDWHに蓄えられているとします。

id timestamp log
1 1234 {"sum_price": 400, "items": [{"item": "りんご", "category": "果物", "num": "3", "price": 100}, {"item": "鉛筆", "category": "文房具", "num": 2, "price": 50}]}
2 1345 {"sum_price": 1500, "items": [{"item": "りんご", "category": "果物", "num": "2", "price": 100}, {"item": "みかん", "category": "果物", "num": 6, "price": 50}, {"item": "洗剤", "category": "日用品", "num": 1, "price": 1000}]}

このときに、次のように下記のように1つの買い物に対してカテゴリごとに売り上げを集計できるようにしたいとします。
つまり、JSONのitemsのバリューである配列の要素としてあるJSONのcategoryごとにまとめたいとします。

id timestamp category items sum_price
1 1234 果物 [{"item": "りんご", "category": "果物", "num": "3", "price": 100}] 300
1 1234 文房具 [{"item": "鉛筆", "category": "文房具", "num": 2, "price": 50}] 100
2 1345 果物 [{"item": "りんご", "category": "果物", "num": "2", "price": 100}, {"item": "みかん", "category": "果物", "num": 6, "price": 50}] 500
2 1345 日用品 [{"item": "洗剤", "category": "日用品", "num": 1, "price": 1000}] 1000

環境の用意

MySQLのコンテナを立ち上げ

docker run --platform linux/x86_64 -p 3307:3306 -e MYSQL_ROOT_PASSWORD=my-secret-pw -d mysql:latest

MySQLクライアントから接続

mysql -uroot -pmy-secret-pw -h127.0.0.1 -P3307

mysqlクライアントがない場合には

sudo apt install mysql-client-core-8.0

データの準備

データベース作成
mysql> CREATE DATABASE sample;
Query OK, 1 row affected (0.01 sec)

mysql> use sample;
Database changed
テーブルの設計
mysql> CREATE TABLE `logs` ( `id` INT unsigned NOT NULL AUTO_INCREMENT, `log` JSON, `created_at` TIMESTAMP NOT NULL, UNIQUE KEY `id` (`id`));
Query OK, 0 rows affected (0.02 sec)

mysql> show tables;
+------------------+
| Tables_in_sample |
+------------------+
| logs             |
+------------------+
1 row in set (0.00 sec)

mysql> show columns in logs;
+------------+--------------+------+-----+---------+----------------+
| Field      | Type         | Null | Key | Default | Extra          |
+------------+--------------+------+-----+---------+----------------+
| id         | int unsigned | NO   | PRI | NULL    | auto_increment |
| log        | json         | YES  |     | NULL    |                |
| created_at | timestamp    | NO   |     | NULL    |                |
+------------+--------------+------+-----+---------+----------------+
3 rows in set (0.00 sec)
データの挿入
mysql> INSERT INTO logs (log, created_at) VALUES ('{"sum_price": 400, "items": [{"item": "りんご", "category": "果物", "num": "3", "price": 100}, {"item": "鉛筆", "category": "文房具", "num": 2, "price": 50}]}', '2023-01-01 11:11:11'), ('{"sum_price": 1500, "items": [{"item": "りんご", "category": "果物", "num": "2", "price": 100}, {"item": "みかん", "category": "果物", "num": 6, "price": 50}, {"item": "洗剤", "category": "日用品", "num": 1, "price": 1000}]}', '2023-01-02 13:02:10');
Query OK, 2 rows affected (0.01 sec)
Records: 2  Duplicates: 0  Warnings: 0

JSON型及びTIMESTAMP型のデータの挿入はこの記事を参考に書きました。
dev.mysql.com
dev.mysql.com

データの確認
mysql> SELECT id, created_at, log FROM logs;
+----+---------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| id | created_at          | log                                                                                                                                                                                                                                             |
+----+---------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|  1 | 2023-01-01 11:11:11 | {"items": [{"num": "3", "item": "りんご", "price": 100, "category": "果物"}, {"num": 2, "item": "鉛筆", "price": 50, "category": "文房具"}], "sum_price": 400}                                                                                  |
|  2 | 2023-01-02 13:02:10 | {"items": [{"num": "2", "item": "りんご", "price": 100, "category": "果物"}, {"num": 6, "item": "みかん", "price": 50, "category": "果物"}, {"num": 1, "item": "洗剤", "price": 1000, "category": "日用品"}], "sum_price": 1500}                |
+----+---------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
2 rows in set (0.00 sec)

JSONに対する基本操作

JSONのあるキーの値を取得

JSONからキーを指定して値を抽出するのはJSON_EXTRACT(カラム名, '$.キー名')でいけます。
今回はitemsの要素である配列に注目したいためJSON_EXTRACT(log, '$.items')で抽出できます。

mysql> SELECT id, created_at, JSON_EXTRACT(`log`, '$.items') FROM logs;
+----+---------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| id | created_at          | JSON_EXTRACT(`log`, '$.items')                                                                                                                                                                                    |
+----+---------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|  1 | 2023-01-01 11:11:11 | [{"num": "3", "item": "りんご", "price": 100, "category": "果物"}, {"num": 2, "item": "鉛筆", "price": 50, "category": "文房具"}]                                                                                 |
|  2 | 2023-01-02 13:02:10 | [{"num": "2", "item": "りんご", "price": 100, "category": "果物"}, {"num": 6, "item": "みかん", "price": 50, "category": "果物"}, {"num": 1, "item": "洗剤", "price": 1000, "category": "日用品"}]                |
+----+---------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
2 rows in set (0.00 sec)

配列を平坦化

このままでは「商品」を表すJSONが「1買い物」を表す配列の中に入っておりカテゴリごとにグループ化ができません。
そこでグループ化できるように、「商品」を表すJSONごとにレコードを作ってみます。

mysql> SELECT logs.id, logs.created_at, t.* FROM logs, JSON_TABLE(log, '$.items[*]' COLUMNS (item varchar(10) PATH '$.item', num int PATH '$.num', price int PATH '$.price', category varchar(10) PATH '$.category')) t;
+----+---------------------+-----------+------+-------+-----------+
| id | created_at          | item      | num  | price | category  |
+----+---------------------+-----------+------+-------+-----------+
|  1 | 2023-01-01 11:11:11 | りんご    |    3 |   100 | 果物      |
|  1 | 2023-01-01 11:11:11 | 鉛筆      |    2 |    50 | 文房具    |
|  2 | 2023-01-02 13:02:10 | りんご    |    2 |   100 | 果物      |
|  2 | 2023-01-02 13:02:10 | みかん    |    6 |    50 | 果物      |
|  2 | 2023-01-02 13:02:10 | 洗剤      |    1 |  1000 | 日用品    |
+----+---------------------+-----------+------+-------+-----------+
5 rows in set (0.00 sec)

平坦化の考え方はもとのJSONのレコードと、配列の各要素に分解した値を部分クロス結合するというものです。
分かりやすいように全カラムを表示してみます。
元テーブルの「1買い物」を表す配列に対して配列の要素である「商品」のJSONを部分クロス結合しているに過ぎません。

mysql> SELECT logs.id, logs.created_at, logs.log,  t.* FROM logs, JSON_TABLE(log, '$.items[*]' COLUMNS (num varchar(10) PATH '$.num', price int PATH '$.price', category varchar(10) PATH '$.category')) t;
+----+---------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+------+-------+-----------+
| id | created_at          | log                                                                                                                                                                                                                                             | num  | price | category  |
+----+---------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+------+-------+-----------+
|  1 | 2023-01-01 11:11:11 | {"items": [{"num": "3", "item": "りんご", "price": 100, "category": "果物"}, {"num": 2, "item": "鉛筆", "price": 50, "category": "文房具"}], "sum_price": 400}                                                                                  | 3    |   100 | 果物      |
|  1 | 2023-01-01 11:11:11 | {"items": [{"num": "3", "item": "りんご", "price": 100, "category": "果物"}, {"num": 2, "item": "鉛筆", "price": 50, "category": "文房具"}], "sum_price": 400}                                                                                  | 2    |    50 | 文房具    |
|  2 | 2023-01-02 13:02:10 | {"items": [{"num": "2", "item": "りんご", "price": 100, "category": "果物"}, {"num": 6, "item": "みかん", "price": 50, "category": "果物"}, {"num": 1, "item": "洗剤", "price": 1000, "category": "日用品"}], "sum_price": 1500}                | 2    |   100 | 果物      |
|  2 | 2023-01-02 13:02:10 | {"items": [{"num": "2", "item": "りんご", "price": 100, "category": "果物"}, {"num": 6, "item": "みかん", "price": 50, "category": "果物"}, {"num": 1, "item": "洗剤", "price": 1000, "category": "日用品"}], "sum_price": 1500}                | 6    |    50 | 果物      |
|  2 | 2023-01-02 13:02:10 | {"items": [{"num": "2", "item": "りんご", "price": 100, "category": "果物"}, {"num": 6, "item": "みかん", "price": 50, "category": "果物"}, {"num": 1, "item": "洗剤", "price": 1000, "category": "日用品"}], "sum_price": 1500}                | 1    |  1000 | 日用品    |
+----+---------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+------+-------+-----------+
5 rows in set (0.00 sec)

MySQLで配列を平坦にするのは下記の構文で得られます。

JSON_TABLE(JSON型のカラム, 配列となるようなクエリ COLUMNS (tのカラム tのカラムの型 PATH クエリによって得られた1要素に対するクエリ)) AS t

このStackOverflowの例がドキュメントよりも分かりやすかったです。
stackoverflow.com
この操作に関してはAthenaやBigQueryのほうが100倍分かりやすいです。
docs.aws.amazon.com
cloud.google.com

商品のカテゴリごとにグループ化

現在この状態のviewが得られているので、次は問題であった(「1買い物」,「商品のカテゴリ」)ごとにグループ化してみましょう。

まず、品ごとの合計値を求めます。

mysql> SELECT logs.id, logs.created_at, t.*, t.num * t.price AS sum_price FROM logs, JSON_TABLE(log, '$.items[*]' COLUMNS (item varchar(10) PATH '$.item', num int PATH '$.num', price int PATH '$.price', category varchar(10) PA
TH '$.category')) t;
+----+---------------------+-----------+------+-------+-----------+-----------+
| id | created_at          | item      | num  | price | category  | sum_price |
+----+---------------------+-----------+------+-------+-----------+-----------+
|  1 | 2023-01-01 11:11:11 | りんご    |    3 |   100 | 果物      |       300 |
|  1 | 2023-01-01 11:11:11 | 鉛筆      |    2 |    50 | 文房具    |       100 |
|  2 | 2023-01-02 13:02:10 | りんご    |    2 |   100 | 果物      |       200 |
|  2 | 2023-01-02 13:02:10 | みかん    |    6 |    50 | 果物      |       300 |
|  2 | 2023-01-02 13:02:10 | 洗剤      |    1 |  1000 | 日用品    |      1000 |
+----+---------------------+-----------+------+-------+-----------+-----------+
5 rows in set (0.00 sec)

上記のテーブルをpとしてp.idとp.categoryでグループ化をしp.sum_priceの和をとれば完成です。

mysql> SELECT p.id, p.category, SUM(p.sum_price) FROM (SELECT logs.id, logs.created_at, t.*, t.num * t.price AS sum_price FROM logs, JSON_TABLE(log, '$.items[*]' COLUMNS (item varchar(10) PATH '$.item', num int PATH '$.num', p
rice int PATH '$.price', category varchar(10) PATH '$.category')) t) p GROUP BY p.id, p.category;
+----+-----------+------------------+
| id | category  | SUM(p.sum_price) |
+----+-----------+------------------+
|  1 | 果物      |              300 |
|  1 | 文房具    |              100 |
|  2 | 果物      |              500 |
|  2 | 日用品    |             1000 |
+----+-----------+------------------+
4 rows in set (0.00 sec)