MySQL8.0.13でMongoDBからMySQLへJSONデータをインポート

MySQL8.0はGAとなった8.0.11以降のメンテナンスリリースでも新機能のリリースを行うことが公式に言及されており、今回ご紹介するMySQL ShellのJSONインポート関数も8.0.13で新しく追加されました。
本記事では[MySQL Server Teamのブログ記事](https://mysqlserverteam.com/importing-data-from-mongodb-to-mysql-using-javascript/)そのままの内容ですがMongoDBからMySQLへのJSONデータインポートを試したいと思います。

検証環境は以下の通りです。簡単のためMySQLとMongoDBは同一インスタンスにインストールしています。
・CentOS7.5 (Vagrantで構築）
・MySQL8.0.13
・MongoDB4.0.4

# 事前準備
まずMongoDBにサンプルデータを用意し、それをmongoexportでJSONファイルとして出力します。
上記ブログ記事の末尾にサンプルデータのリンクがありましたのでそちらを使用します。

```
$ mongoexport --db test --collection restaurants --out restaurants_mongo.json
2018-10-08T18:38:19.104+0100 connected to: localhost
2018-10-08T18:38:19.633+0100 exported 25359 records

$ mongoexport --db test --collection neighborhoods --out neighborhoods_mongo.json
2018-10-08T18:38:45.923+0100 connected to: localhost
2018-10-08T18:38:46.382+0100 exported 195 records
```

# JSONファイルのインポート
MySQL Shellを使用してtestデータベースにJSONファイルをインポートします。
JavaScriptモードとPythonモードそれぞれに関数が用意されています。

・Javascriptモードの場合

```
$ mysqlsh -uroot -p --js

# neighborhoods_mongo.jsonをコレクション形式でインポート
 MySQL  localhost:33060+ ssl  test  JS > util.importJson("/home/vagrant/neighborhoods_mongo.json", {schema: "test", collection: "neighborhoods", convertBsonOid: true});
Importing from file "/home/vagrant/neighborhoods_mongo.json" to collection `test`.`neighborhoods` in MySQL Server at localhost:33060

.. 195.. 195
Processed 4.14 MB in 195 documents in 0.2959 sec (658.93 documents/s)
Total successfully imported documents 195 (658.93 documents/s)


# restaurants_mongo.jsonをテーブル形式でインポート
 MySQL  localhost:33060+ ssl  JS > util.importJson("/home/vagrant/restaurants_mongo.json", {schema: "test", table: "restaurants", convertBsonOid: true});
Importing from file "/home/vagrant/restaurants_mongo.json" to table `test`.`restaurants` in MySQL Server at localhost:33060

.. 25359.. 25359
Processed 3.52 MB in 25359 documents in 0.5532 sec (45.84K documents/s)
Total successfully imported documents 25359 (45.84K documents/s)
```

・Pythonモードの場合

```
$ mysqlsh -uroot -p --py

# neighborhoods_mongo.jsonをコレクション形式でインポート
 MySQL  localhost:33060+ ssl  test  Py > util.import_json("/home/vagrant/neighborhoods_mongo.json", {"schema": "test", "collection": "neighborhoods", "convertBsonOid": True})
Importing from file "/home/vagrant/neighborhoods_mongo.json" to collection `test`.`neighborhoods` in MySQL Server at localhost:33060

.. 195.. 195
Processed 4.14 MB in 195 documents in 0.2748 sec (709.66 documents/s)
Total successfully imported documents 195 (709.66 documents/s)


# restaurants_mongo.jsonをテーブル形式でインポート
 MySQL  localhost:33060+ ssl  test  Py > util.import_json("/home/vagrant/restaurants_mongo.json", {"schema": "test", "table": "restaurants", "convertBsonOid": True})
Importing from file "/home/vagrant/restaurants_mongo.json" to table `test`.`restaurants` in MySQL Server at localhost:33060

.. 25359.. 25359
Processed 3.52 MB in 25359 documents in 0.5541 sec (45.77K documents/s)
Total successfully imported documents 25359 (45.77K documents/s)
```

これらのコマンドによってJSONファイルがインポートされます。（テーブルはDB内に存在しない場合でも自動的に作成されます）
それぞれ2つのファイルをインポートする際に第二引数のフィールド名として"collection"もしくは"field"を指定していますが、これらはインポート時のオプションであり
・MySQL Document Storeの機能を最大限に享受する場合は"collection"を使用する。"collection"を指定した場合、値として明示された正しいコレクション（または値が存在しないもの）のみ処理を許可し、それ以外はエラーとする。
・SQLのみの操作によるJSON型データまたはJSON関数を扱う場合は"table"オプションを使用する。すでに存在しているテーブルにインポートする場合なども同様。
とあります。（[Server Teamの過去記事もご参照ください](https://mysqlserverteam.com/import-json-to-mysql-made-easy-with-the-mysql-shell/)）
インポートした後のデータ形式の差異については次項で詳しく見ていきます。

また、"convertBsonOid"オプションをtrueに設定することによりMongoDBのObject Idを正しい値に変換することができます。（JSONファイルでは{"\_id": {"$oid":"55cba2476c522cafdb053add"}, ...}」となっている値を「{"\_id": "55cba2476c522cafdb053add", ...}」といった形に変換）

上記では使用していませんが、他にtableColumnというオプションでデフォルトでは"doc"となるJSONカラムの名称を指定することができます。


# インポートした値およびコレクション形式とテーブル形式の差異について
JSONファイルをインポートした結果について見ていきます。（以下はJavaScriptモードの例です）

先程コレクション形式で生成したneighborhoodsについてはdb.getCollections()関数によって取得することができます。一方、テーブル形式で生成したrestaurantsは取得されません。

```
 MySQL  localhost:33060+ ssl  mysql  JS > \use test
Default schema `test` accessible through db.

 MySQL  localhost:33060+ ssl  test  JS > db.getCollections();
[
    <Collection:neighborhoods>
]
```

SHOW TABLESにより、いずれの形式においてもテーブルとして生成されていることがわかります。

```
 MySQL  localhost:33060+ ssl  test  JS > session.sql("SHOW TABLES");
+--------------------------+
| Tables_in_test           |
+--------------------------+
| neighborhoods            |
| restaurants              |
+--------------------------+
2 rows in set (0.0017 sec)
```


それぞれSHOW CREATE TABLEでの結果は以下のようになっています。

```
# コレクション形式の場合
 MySQL  localhost:33060+ ssl  test  JS > session.sql("SHOW CREATE TABLE test.neighborhoods");
+---------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Table         | Create Table                                                                                                                                                                                                                                                      |
+---------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| neighborhoods | CREATE TABLE `neighborhoods` (
  `doc` json DEFAULT NULL,
  `_id` varbinary(32) GENERATED ALWAYS AS (json_unquote(json_extract(`doc`,_utf8mb4'$._id'))) STORED NOT NULL,
  PRIMARY KEY (`_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci |
+---------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.0009 sec)

# テーブル形式の場合
 MySQL  localhost:33060+ ssl  test  JS > session.sql("SHOW CREATE TABLE test.restaurants");
+-------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Table       | Create Table                                                                                                                                                                                                 |
+-------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| restaurants | CREATE TABLE `restaurants` (
  `doc` json DEFAULT NULL,
  `id` int(11) NOT NULL AUTO_INCREMENT,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=25360 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci |
+-------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.0007 sec)
```

生成されるID列（プライマリキー）が、コレクションの場合はJSONの\_idフィールドを使用したGENERATEDカラムであるのに対し、
テーブルの場合はAUTO INCREMENTなidカラムとなります。


# インポートしたJSONデータから新しい列を生成する
ここまでの例でも分かる通り、インポートしたJSONデータはそのままだとJSON型カラムにすべて収納された形となっています。が、このままではインデックスを用いた高速なスキャンを行うことができず、またデータを一部だけ使用することが難しい状態です。次の例ではJSONデータから新しい列を生成してみます。

まず、新しいJSONデータセットであるprimer-dataset.jsonをmy_restaurantsテーブルとしてインポートし、JSON関数によってフィールドの値を取得する場合の実行計画を見てみます。

```
# JSONデータをインポート
 MySQL  localhost:33060+ ssl  JS > util.importJson("/home/vagrant/primer-dataset.json", {schema: "test", table: "my_restaurants"});
Importing from file "/home/vagrant/primer-dataset.json" to table `test`.`my_restaurants` in MySQL Server at localhost:33060

.. 25359.. 25359
Processed 11.85 MB in 25359 documents in 1.2729 sec (19.92K documents/s)
Total successfully imported documents 25359 (19.92K documents/s)

# 出力結果を縦にする（SQLモードでの末尾\Gに相当）
 MySQL  localhost:33060+ ssl  JS > shell.options.outputFormat = "vertical";
vertical

# 実行計画を取得
 MySQL  localhost:33060+ ssl  JS > session.sql('EXPLAIN SELECT doc->>"$.name" AS name FROM test.my_restaurants WHERE doc->>"$.cuisine" = "Italian"');
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: my_restaurants
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 24281
     filtered: 100
        Extra: Using where
1 row in set, 1 warning (0.0475 sec)
Note (code 1003): /* select#1 */ select json_unquote(json_extract(`test`.`my_restaurants`.`doc`,'$.name')) AS `name` from `test`.`my_restaurants` where (json_unquote(json_extract(`test`.`my_restaurants`.`doc`,'$.cuisine')) = 'Italian')
```

インデックスがないためテーブルフルスキャンが実行されようとしています。（type: ALLおよびrowsの数に注目）

次に、JSON型カラムからcuisineフィールドを仮想列としてGENERATEし、そこにインデックスを作成したのち実行計画を見てみます。

```
# cuisineフィールドを仮想列として作成
 MySQL  localhost:33060+ ssl  JS > session.sql('ALTER TABLE test.my_restaurants ADD COLUMN cuisine VARCHAR(80) GENERATED ALWAYS AS (doc->>"$.cuisine") VIRTUAL, WITH VALIDATION');
Query OK, 25359 rows affected (0.3038 sec)

# 仮想列にインデックスを作成
 MySQL  localhost:33060+ ssl  JS > session.sql('ALTER TABLE test.my_restaurants ADD INDEX cuisine_idx (cuisine)');
Query OK, 0 rows affected (0.1667 sec)

# 実行計画を取得
 MySQL  localhost:33060+ ssl  JS > session.sql('EXPLAIN SELECT doc->>"$.name" AS name FROM test.my_restaurants WHERE cuisine = "Italian"');
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: my_restaurants
   partitions: NULL
         type: ref
possible_keys: cuisine_idx
          key: cuisine_idx
      key_len: 323
          ref: const
         rows: 1069
     filtered: 100
        Extra: NULL
1 row in set, 1 warning (0.0011 sec)
Note (code 1003): /* select#1 */ select json_unquote(json_extract(`test`.`my_restaurants`.`doc`,'$.name')) AS `name` from `test`.`my_restaurants` where (`test`.`my_restaurants`.`cuisine` = 'Italian')
```

type: refとなり、rowsの数も大幅に減少していることからインデックスが使用され、スキャン効率が向上していることがわかります。（MySQL Workbenchでより視覚的に分かりやすく確認することができます）

# 最後に
以上のように、MySQL8.0.13ではJSONデータをかなり扱いやすくなりました。"NoSQL + SQL"を大々的に推していることからもかなり気合を入れて開発されていることが感じられ、今後ドキュメントストアを導入する場合は選択肢として挙がってくるのではないでしょうか。