LOAD DATA LOCAL INFILEで気を付けること（DBからCSVに書き出してDBにロードするとき）

この記事は[Qiita x Code Polaris共催！女性ITエンジニアが作るアドベントカレンダー Advent Calendar 2022](https://qiita.com/advent-calendar/2022/qiita-code-polaris)の9日目の記事です。
Qiita初投稿です。このカレンダーに参加することで背中を押してもらい記事を書くことが出来ました。ありがとうございます。
実務半年ほどのよわよわエンジニアなので認識違いなどありましたら指摘いただけるとありがたいです。

# 概要
業務の中で大量データ（最大で40万件ほどのレコードのテーブル）をMySQLの旧DBから新DBへ移行する機会があり、`LOAD DATA LOCAL INFILE`を使ったのですが、その時一筋縄ではいかず苦労したので、備忘録もかけてこの記事を書きました。

# この記事で伝えたいこと
- `LOAD DATA LOCAL INFILE`がざっと何をするのか
- `LOAD DATA LOCAL INFILE`でデータをロードするときに気を付けること
- DBのデータをCSVに書き出すときに気を付けること

# この記事で書かないこと
- `LOAD DATA LOCAL INFILE`のパラメータやオプションなどの詳しい説明は公式に譲ります。

# LOAD DATA LOCAL INFILEって？
https://dev.mysql.com/doc/refman/8.0/ja/load-data.html

そもそもMySQLに`LOAD DATA INFILE`という構文がありましてこれは、CSVなどのテキストファイルからデータをテーブルに取り込みます。
`LOAD DATA INFILE`は`LOCAL`修飾子を付けないとサーバー上にファイルが無いといけないのですが、`LOCAL`を付けるとクライアント側のファイルを使用できます。

テーブルにデータを入れる際にdumpファイルでインポートする方法もありますが、dumpはSQLの集まりなので結果的にINSERT文をSQLで実行しています。

リファレンスによると[LOAD DATAステートメントはINSERTステートメントより20倍速い](https://dev.mysql.com/doc/refman/8.0/ja/insert-optimization.html)そうです。

今回40万件のテーブルがあり移行する時間を短くしたかったので`LOAD DATA LOCAL INFILE`を選定するに至りました。

# 環境
PHP8.0.23
Laravel 9.23.0
MySQL8.0.29

# サンプルコード
```DatabaseImporter.php
<?php
namespace App\Console\Commands;


use Illuminate\Console\Command;
use Exception;
use App\Services\Migrate\OldUserImporterService;
class DatabaseImporter extends Command
{
    protected $signature = 'import:all';

    public function handle()
    {
        try {
            $service = new OldUserImporterService();
            $service->exec();
        } catch (Exception $e) {
            //エラー処理
        }
        return 'SUCCESS';
    }
}
```
コマンドで実行する実装だったのでLaravelのCommandを使用して書いてます。
上のサンプルだと`php artisan import:all`と打つとデータ移行が実行されます。

コマンドで実行されるサービスは以下になります。
ここでは移行元のuserテーブルのデータをCSVに書き出しています。
```OldUserImporterService.php
<?php
namespace App\Services;

use Exception;
use DB;
use App\Models\OldUser;
use App\Libraries\FileCtrl;

class OldUserImporterService
{
    private $_file_name  = 'OldUserImporterService';

    public function exec()
    {
        $count = OldUser::count();
        if ($count > 0) {
            //ファイル操作をいい感じにやるライブラリ（主題じゃないので解説省略）
            $f = new FileCtrl();
            $f->open($this->_file_name);
            $insert_counter = 0;

            //移行元のデータをCSVに書き出す
            $records = DB::table('old_users')->get();
            if ($records) {
                foreach ($records as $record) {
                    $set_data  = '';
                    $set_data .= '"' . addslashes($record->id) . '",';
                    $set_data .= is_null($record->display_name) ? 'NULL' :  '"'. addslashes($record->display_name) .'"';
                    $set_data .= '"' . addslashes($record->email) . '",';
                    $set_data .= "\n";
                    $f->write($set_data);
                    $insert_counter++;
                }
                $f->close();
            }
        }

        if ($insert_counter > 0) {
            //移行先のDBを指定する
            DB::connection('new_connection');
            DB::statement('TRUNCATE databases.new_users;');

            foreach ($f->get_files() as $file) {
                $this->_import($file);
                // 処理したファイルは削除する
                unlink($file);
            }
        } else {
            $f->unlinks();
        }
        return;
    }
}
```
肝心の`LOAD DATA`部分はこちら。

```OldUserImporterService.php
private function _import($file_path)
    {
        try {
            DB::beginTransaction();
            //移行先のDBを指定する
            DB::connection('new_connection');

            //外部キー制約無効
            DB::statement('SET foreign_key_checks = 0;');
            // CSV取り込み
            $insert_sql = "LOAD DATA LOCAL INFILE '" . $file_path . "' INTO TABLE database.new_user "
                . "FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' "
                . "(id,"
                . "name,"
                . "email);";

            DB::statement($insert_sql);
            DB::statement('SET foreign_key_checks = 1;');
            DB::commit();

        } catch (Exception $e) {
            DB::rollback();
            //エラー処理
        }
        return;
    }
```
# 気を付けること（主題）

### 1.local_infileの設定をサーバーとクライアント両方で有効にする

このようなエラーが出たときはサーバー側かクライアント側どちらかまたは両方のlocal_infileが無効になっています。

```
ERROR 3950 (42000): Loading local data is disabled; this must be
enabled on both the client and server side
```

**サーバー側:** 
デフォルトで無効になっているので
```
mysql> SET GLOBAL local_infile=on;

mysql> select @@local_infile;
+----------------+
| @@local_infile |
+----------------+
|              1 |
+----------------+
1 row in set (0.00 sec)
```

**クライアント側:**
MySQLログイン時に以下のようにオプションを指定します。
```
$ mysql -u root -p --local_infile=1
```
Laravelの場合config/database.phpに下記を追加します。
```diff_php
'mysql' => [
            //省略  
            'options' => extension_loaded('pdo_mysql') ? array_filter([
                PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
+               PDO::ATTR_EMULATE_PREPARES => true, 
+               PDO::MYSQL_ATTR_LOCAL_INFILE => true, 
            ]) : [],
        ],
```


### 2. 予約語やハイフン含むテーブル名はバッククォートで囲む
これは`LOAD DATA`関係なくMySQLの約束事ですが、MySQL側で特別な意味を持つ単語を予約語（SELECT、DELETE、BIGINなど）といい、予約語のカラム名やテーブル名はバッククォートで囲まないと構文エラーになります。
[予約語一覧](https://dev.mysql.com/doc/refman/8.0/ja/keywords.html)
予約語を使うのはなるべく避けたいですが意図せず引っかかってる場合もありますのでご注意ください。

```
(`key`, `group`, `select`)
```

あとハイフンが入ったテーブル名もバッククォートで囲まないと構文エラーになります（最近知りました…）。
```
INTO TABLE `data-base`.user
```

### 3.CSVに書き出すときエスケープ処理を入れる
この部分ですね。

```OldUserImporterService.php
$set_data .= '"' . addslashes($record->email) . '",';
```
初歩的な部分ですが、CSVの区切り文字`,`カンマや、囲み文字`"`ダブルクォート、`'`シングルクォート、などがデータに含まれてるとCSVが正しく記述できません。

業務では、正しく記述されなくてもエラー起きないのでそのまま`LOAD DATA`されてしまって移行時にデータが壊れて旧DBと新DBで差分が出てしまう事態になりました…。

というわけでエスケープ処理が必要です。
PHPにはそのものズバリな[addslashes](https://www.php.net/manual/ja/function.addslashes.php)関数がありましてエスケープが必要な文字にバックスラッシュを付けて返してくれます。

今回の例だとエスケープしたデータを`"`ダブルクォートで囲んで`,`カンマで区切っています。

### 4.NULLの扱いについて確認する
これもCSVを書き出すときの注意ですが、以下の部分では旧DBのデータが`NULL`だった場合`'NULL'`を入れる処理をしています。（PHPの`fwrite()`で書き出してます）

```OldUserImporterService.php
$set_data .= is_null($record->display_name) ? 'NULL' :  '"'. addslashes($record->display_name) .'"';
```
CSVに書き出したときに以下の差異があります。
```.csv
//DBのNULLデータをそのまま書き出した場合
"19",,"email"
//'NULL'に変換して書き出した場合
"19",NULL,"email"
```
`NULL`が空欄に変換されてる！！！
当然エラーが出ずそのまま`LOAD DATA`されますが空文字が`NULL`に変換されるわけもなく、どうなるかというとリファレンスにさらっとこう書かれています。

>入力行に含まれるフィールドが少なすぎる場合、入力フィールドがないテーブルカラムはそのデフォルト値に設定されます。 デフォルト値の割り当てについては、セクション11.6「データ型デフォルト値」で説明されています。
引用元：https://dev.mysql.com/doc/refman/8.0/ja/load-data.html

明示的に`DEFAULT`を指定していないカラムの場合暗黙的なデフォルト値になります。

>暗黙的なデフォルトは次のように定義されます。

>- 数値型のデフォルトは 0 です。ただし、例外として AUTO_INCREMENT 属性で宣言された整数型または浮動小数点型のデフォルトは、そのシーケンスの次の値になります。

>- TIMESTAMP 以外の日付と時間型のデフォルトには、「ゼロ」値が適切です。 explicit_defaults_for_timestamp システム変数が有効な場合、これは TIMESTAMP にも当てはまります (セクション5.1.8「サーバーシステム変数」を参照してください)。 それ以外の場合、テーブルの最初の TIMESTAMP カラムのデフォルト値は現在の日付と時間になります。 セクション11.2「日時データ型」を参照してください。

>- ENUM ではない文字列型のデフォルト値は空の文字列です。 ENUM のデフォルトは、最初の列挙値です。
引用元：https://dev.mysql.com/doc/refman/8.0/ja/data-type-defaults.html

これも業務での移行時に旧DBと新DBで差分が出てしまう事故になりました…。
CSVに書き出すときの挙動とMySQLのデフォルト値の挙動を理解していなかったため起きた事故ですのでベテランの方は当たり前に考慮されてるかもしれませんが失敗談として記しました。
皆さんもお気を付けください。
（ここまで面倒なら別の技術選定した方がいいかもしれませんが）