MySQL道普請便り

第142回MySQLのgroup byについて

集計をする上で必要となるgroup byですが、MySQLではちょっと変わった形の(SQL標準とは挙動が異なる)実行することもできます。MySQL 5.6以前のMySQLを利用した方の中には、お世話になった人も現在奮闘されている方も多いのではないかなと思います。

今回はMySQLの拡張されたgroup byについて説明していきたいと思います。

検証環境

今回は第125回 phpMyAdminでDockerで建てたMySQLにアクセスするで記載したdocker-composeを利用して作成します。手元で簡単に試せるように、GitHubのわたしのレポジトリにサンプルコードとして置いてあるので、気軽に試したい方はgit cloneして試してみてください。試すにはdockerとdocker-composeが必要です。

group byのMySQL拡張

今回の検証はMySQLの独自拡張を利用することになるため、第18回 MySQL5.7のデフォルトのSQLモードを確認してみるで紹介したONLY_FULL_GROUP_BYを無効にする必要があります。

まずは今回紹介したいgroup by拡張ですが、以下のようなクエリを実行することができます。

select * from zipcode group by zip_code;

SQLモードを設定せずに上記のSQLを現行のMySQLで実行すると、以下のようにエラーとなります。

mysql> select * from zipcode group by zip_code;
ERROR 1055 (42000): Expression #1 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'zipcode.zipcode.code' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by

SELECTのリストの中でGROUP BYの中に含まれていないカラムと集約関数を通してないカラムが含まれているためエラーになっていて、これはsql_mode=only_full_group_byと非互換であると書かれています。

では、sql_modeを空っぽにしてみましょう。

SQLモードを指定する方法は、第60回 SQLモードについて[その1]でglobalセッションに指定する方法と、第31回 MySQLのオプションファイル my.cnfの豆知識[その1]で、my.cnfを指定する方法を紹介してきました。

手元で試してみたい場合は、globalセッションで登録してしまうと良いかもしれません。今回は、my.cnfをdocker-composeで指定する方法を紹介します。

docker-composeでmy.cnfを設定する

まずはじめに、現状のSQLモードについて確認してみましょう。

mysql> select @@sql_mode;
+-----------------------------------------------------------------------------------------------------------------------+
| @@sql_mode                                                                                                            |
+-----------------------------------------------------------------------------------------------------------------------+
| ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION |
+-----------------------------------------------------------------------------------------------------------------------+

以下のように、mycnfディレクトリに設定したいmy.cnfを配置しました。

$ tree
.
├── Dockerfile
├── README.md
├── docker-compose.yml
├── docker-entrypoint-initdb.d
│   ├── 1_ddl.sql
│   ├── 2_load_data_infile.sql
│   └── KEN_ALL_UTF8.CSV
└── mycnf
    └── my.cnf

今回のmy.cnfの内容は、以下の通りになります。

[mysqld]
sql_mode=""

docker-compose.ymlのvolumesで、上記のマウントの設定を追加します。

version: '3'
services:
  mysql:
    build: .
    volumes:
      - ./docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d
      - ./mycnf/my.cnf:/etc/mysql/my.cnf # my.cnfをマウントする
    environment:
      MYSQL_ROOT_PASSWORD: password
    command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --secure-file-priv="/docker-entrypoint-initdb.d"

docker-composeを再起動して、sqlモードを確認してみましょう。

mysql> select @@sql_mode;
+------------+
| @@sql_mode |
+------------+
|            |
+------------+

ということで、空にすることができました。

group byのクエリを実行してみる

さて、はじめにエラーとなってしまったSELECT文のクエリを実行してみましょう。

mysql> select * from zipcode group by zip_code;

《 省略 》

| 47382 | 90718       | 9071801  | オキナワケン                | ヤエヤマグンヨナグニチョウ                                                    | ヨナグニ                                                                                                                                                                                                                   | 沖縄県       | 八重山郡             | 与那国                                                                                                             |
+-------+-------------+----------+-----------------------+--------------------------------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+--------------+--------------------------------+--------------------------------------------------------------------------------------------------------------------+
120189 rows in set (0.25 sec)

ということで、SQLモードを変更する前と違って実行することができました。

さて、ここで問題です。今回実行したSQLで*で取れた行の値はどうやって決まるのでしょうか?

この問題を考える前に、まずzip_codeでダブリがどれくらいあるのか見てみましょう。

mysql> select zip_code, count(*) from zipcode group by zip_code having count(zip_code)> 1 order by count(zip_code);
+----------+----------+
| zip_code | count(*) |
+----------+----------+
| 0040000  |        2 |
| 0680546  |        2 |

《 省略 》

0040000の地域は、2個KEN_ALL上に登録されていることがわかります。この地域を例に、以下のようにwhere句でzip_codeが0040000を確認してみましょう。

mysql> select * from zipcode where zip_code = '0040000' ;
+-------+-------------+----------+-----------------------+--------------------------------------+-----------------------------------------------+------------+--------------------+--------------------------------+
| code  | old_zipcode | zip_code | prefecture_kana       | city_kana                            | town_kana                                     | prefecture | city               | town                           |
+-------+-------------+----------+-----------------------+--------------------------------------+-----------------------------------------------+------------+--------------------+--------------------------------+
| 01108 | 004         | 0040000  | ホッカイドウ               | サッポロシアツベツク                         | イカニケイサイガナイバアイ                               | 北海道     | 札幌市厚別区       | 以下に掲載がない場合           |
| 01110 | 004         | 0040000  | ホッカイドウ               | サッポロシキヨタク                           | イカニケイサイガナイバアイ                               | 北海道     | 札幌市清田区       | 以下に掲載がない場合           |
+-------+-------------+----------+-----------------------+--------------------------------------+-----------------------------------------------+------------+--------------------+--------------------------------+

北海道札幌市の厚別区か清田区の「以下に掲載が無い場合」となっています。では、これに対してgroup byをして結果を取得してみたら、どちらが出るかわかりますか?

mysql> select * from zipcode where zip_code = '0040000' group by zip_code;
+-------+-------------+----------+-----------------------+--------------------------------------+-----------------------------------------------+------------+--------------------+--------------------------------+
| code  | old_zipcode | zip_code | prefecture_kana       | city_kana                            | town_kana                                     | prefecture | city               | town                           |
+-------+-------------+----------+-----------------------+--------------------------------------+-----------------------------------------------+------------+--------------------+--------------------------------+
| 01108 | 004         | 0040000  | ホッカイドウ               | サッポロシアツベツク                         | イカニケイサイガナイバアイ                               | 北海道     | 札幌市厚別区       | 以下に掲載がない場合           |
+-------+-------------+----------+-----------------------+--------------------------------------+-----------------------------------------------+------------+--------------------+--------------------------------+
1 row in set (0.07 sec)

今回は厚別区が出ましたが、実はこの挙動は保証されていません。別の環境で実行を行った際には結果が異なることもあり得る、という恐ろしい結果になります。なので、このクエリを実行する際には、結果が不定であるというのを踏まえて実行する必要があります。

不定であることが望ましくない場合には、注意する必要があります。ですが、不定ではないと言い切るには、データセットへの限りない理解が必要で、たとえば郵便番号のデータでよくあるうっかりミスの例として、郵便番号には県をまたぐような場合が無いと勘違いをして、郵便番号から県名を取得するような処理を書こうとしてしまい、おかしなことになってしまう場合などが考えられます。

実際は、県をまたぐ郵便番号が以下のように3個ほどあるのがわかります。

mysql> select zip_code, count(DISTINCT prefecture) from zipcode group by zip_code having count(DISTINCT prefecture) > 1;
+----------+----------------------------+
| zip_code | count(DISTINCT prefecture) |
+----------+----------------------------+
| 4980000  |                          2 |
| 6180000  |                          2 |
| 8710000  |                          2 |
+----------+----------------------------+
3 rows in set (0.20 sec)

それぞれ愛知と三重、大阪と京都、福岡と大分の県境にある市区町村で共有していることがわかります。

mysql> select * from zipcode where zip_code in ('4980000', '6180000', '8710000');
+-------+-------------+----------+--------------------+-----------------------------------------------------+-----------------------------------------------+------------+-----------------------+--------------------------------+
| code  | old_zipcode | zip_code | prefecture_kana    | city_kana                                           | town_kana                                     | prefecture | city                  | town                           |
+-------+-------------+----------+--------------------+-----------------------------------------------------+-----------------------------------------------+------------+-----------------------+--------------------------------+
| 23235 | 498         | 4980000  | アイチケン              | ヤトミシ                                                | イカニケイサイガナイバアイ                               | 愛知県     | 弥富市                | 以下に掲載がない場合           |
| 24303 | 498         | 4980000  | ミエケン               | クワナグンキソサキチョウ                                       | イカニケイサイガナイバアイ                               | 三重県     | 桑名郡木曽岬町        | 以下に掲載がない場合           |
| 26303 | 618         | 6180000  | キョウトフ              | オトクニグンオオヤマザキチョウ                                   | イカニケイサイガナイバアイ                               | 京都府     | 乙訓郡大山崎町        | 以下に掲載がない場合           |
| 27301 | 618         | 6180000  | オオサカフ              | ミシマグンシマモトチョウ                                       | イカニケイサイガナイバアイ                               | 大阪府     | 三島郡島本町          | 以下に掲載がない場合           |
| 40642 | 871         | 8710000  | フクオカケン             | チクジョウグンヨシトミマチ                                     | イカニケイサイガナイバアイ                               | 福岡県     | 築上郡吉富町          | 以下に掲載がない場合           |
| 44203 | 871         | 8710000  | オオイタケン             | ナカツシ                                                | イカニケイサイガナイバアイ                               | 大分県     | 中津市                | 以下に掲載がない場合           |
+-------+-------------+----------+--------------------+-----------------------------------------------------+-----------------------------------------------+------------+-----------------------+--------------------------------+
6 rows in set (0.08 sec)

この状態で郵便番号から県名を取得する際に以下のようにしてしまうと、三重県なのに愛知県と表示されてしまったりすることに繋がり、バグとなります。

mysql> select prefecture from zipcode group by zip_code having zip_code = '4980000';
+------------+
| prefecture |
+------------+
| 愛知県     |
+------------+
1 row in set (0.13 sec)

このように、理解していると思いこんでいるデータセットでも例外となるケースがあったりするため、常に考慮漏れが潜んでいる可能性があります。

また、KEN_ALLに関しては、今後データが増えることが無いと思われるので問題にはなりませんが、今後データが増えるような場合には注意が必要となります。

そのため、できる限りonly_full_group_byを利用して、結果が不定とならないようなクエリを作成するように心がけてください。現在only_full_group_byを利用している場合には、外さないように注意をしましょう。

まとめ

今回紹介したMySQLのgroup by拡張ですが、基本的にこれらは便利ではありますが、データセット次第で結果が不定になってしまう可能性があるため、利用を避けたほうが良いです。現在のデータセットで問題が起きないとしても、将来何らかの問題が発生してしまう可能性もあるため、できる限り使用を避けたほうが良いと思います。

MySQLでは、5.7以降ではSQLモードでデフォルトで使用できなくなってはいますが、それ以前のバージョンで作成していたアプリケーションでMySQLをアップグレードした場合に、SQLの書き換えなどの対応が取れずに無効にしている場合もあるかと思います。そういった場合には、どこかのタイミングでぜひ一度、SQLモードの見直しを進めてみてはいかがでしょうか。

おすすめ記事

記事・ニュース一覧