ゲームのマスターデータを管理するために作ったものいろいろ

最近やったことを雑多に紹介していく。
PHP Advent Calendar見たら枠が空いてたので参加するぞぃ。
Laravel向けのコードが多めですけどゆるして。

# Laravelを使った所感
せっかくなので感想を…
公式に書いてある通り開発効率はとてもよいと思います。
データ管理のページやKPIのページなど大量にコードを作る状況にあったのだけどLaravelのおかげで乗り切れた。
間違いなく開発効率はよい！

速度は…
あまり期待できないですが、特にCollection使いすぎると遅い…。
大量にデータを処理するバッチ処理等では素直に`foreach`で回した方がいいでしょう。
APIとか大量にアクセスがくるところに使うのは悩みどころ。
気になるならLumenがよいかも。

しかし利用者が限られている開発向けのツールならば最適だと思います。
Laravel-adminとセットでどうぞ。
[Laravel-adminを使って実用的な画面を作る。（1対多のリレーションシップに対応）](https://qiita.com/Fea/items/6d17d2488c54d351c6fd)
（まだ実用する機会がないのだが…）

実際にこのような構成で使っていました。
API：Lumen
管理ツール：Laravel

さて、ここからが本題。

# SQLを実行するツール
別途XLSXからSQLを生成するツールがあるのでプログラマでなくても作成できるようにしています。
現在のデータとSQL実行後の差分を表示するようにしました。
ちょっと強引ですがSQLを実行して結果を取得した後にロールバックするという方法を用いています。
これならば実データでの比較が出来てカラム追加等のメンテナンスも不要となるはず…。
複数バージョン同時開発しているので開発環境ごとにカラム構成が違うことがよくある。
またチャットワークへの自動通知も行っていて、何時誰がどの環境で何のテーブルを更新したのか分かるので割と好評でした。

Laravelで開発しているので紹介するコードにはLaravelの書式が含まれます。
紹介するコードでは設定関連をべた書きしてますが、基本的にはLaravelのconfigを使っています。
コードを読むときは以下を参考にされたし。
[Laravel データベース](https://readouble.com/laravel/5.5/ja/database.html)
[Laravel リクエスト](https://readouble.com/laravel/5.5/ja/requests.html)
[Laravel Bladeテンプレート](https://readouble.com/laravel/5.5/ja/blade.html)
[Laravel Recipes Formファサード](http://recipes.laravel.jp/category/17)

データの差分を表示する際に以下のライブラリを使いました。

```
chrisboulton/php-diff
Copyright (c) 2009 Chris Boulton chris.boulton@interspire.com All rights reserved.
```

<details><summary>コード一式（とても長いので折り畳みしておく）</summary><div>

```html:フォーム
{!! Form::open(['url' => '[url]', 'method' => 'post', 'name' => 'form', 'enctype' => 'multipart/form-data']) !!}
<div class="row">
    <div class="form-group">
        <div class='col-sm-4'>
            {!! Form::label('execute_conn_list[]', '環境選択:', ['class' => 'text-left']) !!}
        </div>
    </div>
</div>
<div class="row" style="margin-top:10px;">
    <div class="form-group">
        <div class='col-md-8'>
            {!! Form::label('sql_file', 'SQLファイル:', ['class' => 'text-left']) !!}
            {!! Form::file('sql_file', ['class' => 'form-control']) !!}
        </div>
    </div>
</div>
<div class="row" style="margin-top:10px;">
    <div class='col-md-3'>
        {!! Form::submit('実行', ['class' => 'btn btn-primary']) !!}
    </div>
</div>
{!! Form::close() !!}
```

```php:SQL実行、反映前に差分データチェック
$sql_error = false;
$conn_list = [
    'dev-a' => 'Dev-A',
    'dev-b' => 'Dev-B',
    'dev-c' => 'Dev-C',
];
$table_exclude = ['TABLE_NAME'];  // 除外するテーブル
$column_exclude = ['id', 'created', 'modified']; // 除外するカラム
$db_schema = 'master_data';
$db_table_prefix = 'master_';
$diff_data = [];
$edit_tables = [];
$env_group_name = '';
$execute_conn_list = [];
$check_list = [];
$file_org_name = $requests->get('file_org_name', '');
$execute_conn_list = $requests->get('execute_conn_list', []);
$sql_exec = $requests->get('sql_exec', 0);
$sql = str_replace(["\r\n", "\r"], "\n", $requests->get('sql', ""));
if (empty($sql)) {
    if ($requests->hasFile('sql_file')) {
        $file = $requests->file('sql_file');
        if (!empty($file)) {
            $file_org_name = $file->getClientOriginalName();
            $op = $file->openFile();
            $file_sql = [];
            while (!$op->eof()) {
                $t = trim($op->fgets());
                if (!empty($t)) {
                    $file_sql[] = $t;
                }
                $sql = implode(PHP_EOL, $file_sql);
            }
        }
    }
}
$result_data = [];
$real_sql_array = explode(";", $sql);
if ($sql_exec != 1) {
    $old_master_data = [];
    $new_master_data = [];
    $renderer = new \Diff_Renderer_Html_SideBySide();
    try {
        foreach ($execute_conn_list as $execute_conn) {
            $real_sql = '';
            $sql_table = '';
            $table_list = [];
            $tmp = DB::connection($execute_conn)
                ->table('information_schema.tables')
                ->where('table_schema', $db_schema)
                ->get([
                    'table_name',
                    'table_comment',
                ]);
            foreach ($tmp as $v) {
                $a = (array)$v;
                if (!isset($table_exclude[$a['table_name']])) {
                    $table_list[$a['table_name']] = $a;
                }
            }
            $tmp = DB::connection($execute_conn)
                ->table('information_schema.columns')
                ->where('table_schema', $db_schema)
                ->get([
                    'table_name',
                    'column_name',
                    'column_type',
                    'column_comment',
                ]);
            foreach ($tmp as $v) {
                $a = (array)$v;
                if (!isset($table_exclude[$a['table_name']]) && !isset($column_exclude[$a['column_name']])) {
                    $column_list[$a['table_name']][$a['column_name']] = $a;
                }
            }
            foreach ($table_list as $v) {
                $a = (array)$v;
                $tmp = DB::connection($execute_conn)
                    ->table($db_schema . '.' . $a['table_name'])
                    ->orderBy('id')
                    ->get(['*']);
                foreach ($tmp as $d) {
                    $a2 = (array)$d;
                    foreach ($column_exclude as $ce) {
                        if (isset($a2[$ce])) {
                            unset($a2[$ce]);
                        }
                    }
                    $old_master_data[$a['table_name']][] = implode(',', $a2);
                }
            }
            DB::connection($execute_conn)->beginTransaction();
            foreach ($real_sql_array as $real_sql_one) {
                if (strlen($real_sql_one) === 0) {
                    continue;
                }
                $real_sql = trim($real_sql_one);
                if (preg_match('/`' . $db_table_prefix . '[A-Z_]+`/', $real_sql, $ret)) {
                    $sql_table = trim(str_replace('`', '', $ret[0] ?? ''));
                }
                $lower_key_strings = mb_strtolower($real_sql);
                if (strpos($lower_key_strings, "insert") !== false) {
                    $result_tmp = [];
                    $result_tmp["sql"] = $real_sql;
                    $result_tmp["table"] = $sql_table;
                    $result_tmp["result"] = "SQL実行完了しました";
                    $result_tmp['sql_select'] = 0;
                    $result_tmp['execute_conn'] = $execute_conn;
                    $result_tmp['status'] = DB::connection($execute_conn)->insert($real_sql);
                    $result_data[] = $result_tmp;
                } else {
                    if (
                        strpos($lower_key_strings, "set ") === false && (
                            strpos($lower_key_strings, "create table") !== false || strpos($lower_key_strings, "drop table") !== false ||
                            strpos($lower_key_strings, "delete") !== false || strpos($lower_key_strings, "update") !== false
                        )
                    ) {
                        DB::connection($execute_conn)->statement($real_sql);
                    }
                }
            }
            foreach ($table_list as $v) {
                $a = (array)$v;
                $tmp = DB::connection($execute_conn)
                    ->table($db_schema . '.' . $a['table_name'])
                    ->orderBy('id')
                    ->get(['*']);
                foreach ($tmp as $d) {
                    $a2 = (array)$d;
                    foreach ($column_exclude as $ce) {
                        if (isset($a2[$ce])) {
                            unset($a2[$ce]);
                        }
                    }
                    $new_master_data[$a['table_name']][] = implode(',', $a2);
                }
            }
            DB::connection($execute_conn)->rollBack();
            foreach ($old_master_data as $table_name => $old_data) {
                $diff = new \Diff(
                    array_map('trim', $old_data),
                    array_map('trim', ($new_master_data[$table_name] ?? [])), [
                    'context' => 2,
                ]);
                $diff_html = trim($diff->render($renderer));
                if (!empty($diff_html)) {
                    $diff_data[$execute_conn][$table_name] = $diff_html;
                }
            }
            unset($old_master_data, $new_master_data);
        }
    } catch (\Exception $e) {
        foreach ($execute_conn_list as $execute_conn) {
            DB::connection($execute_conn)->rollBack();
        }
        $sql_error = true;
        $message = explode(PHP_EOL, $e->getMessage())[0] . ' ...' . "\n";
        $message .= "\n";
        $message .= '実行環境：' . $conn_list[$execute_conn] . "\n";
        $result_tmp = [];
        $result_tmp["sql"] = $real_sql;
        $result_tmp["table"] = $sql_table ?? '';
        $result_tmp["result"] = $message;
        $result_tmp['sql_select'] = 0;
        $result_tmp["status"] = 999;
        $result_data[] = $result_tmp;
    }
}
if (!$sql_error) {
    if ($sql_exec != 1) {
        /* DB差分のHTML（$diff_data）を表示 */

    } else {
        $result_data = [];
        try {
            foreach ($execute_conn_list as $execute_conn) {
                $real_sql = '';
                $sql_table = '';
                DB::connection($execute_conn)->transaction(function () use (
                    $execute_conn,
                    $real_sql_array,
                    &$result_data,
                    &$real_sql,
                    &$sql_table,
                    &$edit_tables
                ) {
                    foreach ($real_sql_array as $real_sql_one) {
                        if (strlen($real_sql_one) === 0) {
                            continue;
                        }
                        $ret = [];
                        $real_sql = trim($real_sql_one);
                        if (preg_match('/`' . $db_table_prefix . '[A-Z_]+`/', $real_sql, $ret)) {
                            $sql_table = trim(str_replace('`', '', $ret[0] ?? ''));
                            $edit_tables[$sql_table] = $sql_table;
                        }
                        $lower_key_strings = mb_strtolower($real_sql);
                        if (strpos($lower_key_strings, "insert") !== false) {
                            $result_tmp = [];
                            $result_tmp["sql"] = $real_sql;
                            $result_tmp["table"] = $sql_table;
                            $result_tmp["result"] = "SQL実行完了しました";
                            $result_tmp['sql_select'] = 0;
                            $result_tmp['execute_conn'] = $execute_conn;
                            $result_tmp['status'] = DB::connection($execute_conn)->insert($real_sql);
                            $result_data[] = $result_tmp;
                        } else {
                            if (
                                strpos($lower_key_strings, "set ") === false && (
                                    strpos($lower_key_strings, "delete") !== false ||
                                    strpos($lower_key_strings, "update") !== false
                                )
                            ) {
                                $result_tmp = [];
                                $result_tmp["sql"] = $real_sql;
                                $result_tmp["table"] = $sql_table;
                                $result_tmp["result"] = "SQL実行完了しました";
                                $result_tmp['sql_select'] = 0;
                                $result_tmp['execute_conn'] = $execute_conn;
                                $result_tmp['status'] = DB::connection($execute_conn)->statement($real_sql);
                                $result_data[] = $result_tmp;
                            }
                        }

                    }
                });
            }
        } catch (\Exception $e) {
            $sql_error = true;
            $message = explode(PHP_EOL, $e->getMessage())[0] . ' ...' . "\n";
            $message .= "\n";
            $message .= '実行環境：' . $conn_list[$execute_conn] . "\n";
            $result_tmp = [];
            $result_tmp["sql"] = $real_sql;
            $result_tmp["table"] = $sql_table ?? '';
            $result_tmp["result"] = $message;
            $result_tmp['sql_select'] = 0;
            $result_tmp["status"] = 999;
            $result_data[] = $result_tmp;
        }
        if (!$sql_error) {
            $retry = 3;
            $user = Auth::user();  // Laravel ユーザー情報取得
            $db_list = [];
            foreach ($execute_conn_list as $exec) {
                $db_list[$exec] = $conn_list[$exec] ?? $exec;
            }
            $data_check_result = 'OK';
            $check_list = MasterData::check();  // マスターデータをチェックしてエラーメッセージを配列で返す
            if (!empty($check_list)) {
                $data_check_warning = false;
                $data_check_danger = false;
                foreach ($check_list as $tmp) {
                    foreach ($tmp as $level => $v) {
                        switch ($level) {
                            case 'warning':
                                $data_check_warning = true;
                                break;
                            case 'danger':
                                $data_check_danger = true;
                                break;
                        }
                    }
                }
                if ($data_check_warning == true) {
                    $data_check_result = 'Warning';
                }
                if ($data_check_danger == true) {
                    $data_check_result = 'Error!';
                }
            }
            for ($retry_count = 0; $retry_count < $retry; ++$retry_count) {
                try {
                    $chatwork_api_token = '[チャットワークのAPIトークン]';
                    $chatwork_room_id = '[チャットワークのルームID]';
                    $body = '[info][title]マスターデータ反映通知：SQL実行[/title]';
                    $body .= '実行者： ' . ($user->name ?? '') . "\n";
                    $body .= 'マスターデータチェック： ' . ($data_check_result ?? '') . "\n";
                    if (!empty($file_org_name)) {
                        $body .= 'SQLファイル： ' . "\n";
                        $body .= '　' . $file_org_name . "\n";
                    }
                    $body .= '反映先： ' . "\n";
                    $body .= '　' . (implode(', ', $db_list)) . "\n";
                    $body .= '影響テーブル： ' . "\n";
                    $body .= '　' . (implode("\n　", $edit_tables ?? [])) . "\n";
                    $body .= '[/info]';
                    $headers = [
                        'X-ChatWorkToken: ' . $chatwork_api_token,
                    ];
                    $data = [
                        'body' => $body
                    ];
                    $options = [
                        CURLOPT_POST => true,
                        CURLOPT_SSL_VERIFYPEER => false,
                        CURLOPT_SSL_VERIFYHOST => false,
                        CURLOPT_RETURNTRANSFER => true,
                        CURLOPT_FOLLOWLOCATION => true,
                        CURLOPT_HTTPHEADER => $headers,
                        CURLOPT_POSTFIELDS => http_build_query($data),
                    ];
                    $curl = curl_init("https://api.chatwork.com/v2/rooms/{$chatwork_room_id}/messages");
                    curl_setopt_array($curl, $options);
                    $result = curl_exec($curl);
                    if ($result === false) {
                        throw new \Exception();
                    }
                } catch (\Exception $e) {
                    sleep(1);
                    continue;
                }
                break;
            }
        }
    }
}

/* SQL実行結果を表示 */

```

```html:Laravel(bootstrap?)で表示する時は、php-diffのCSSを強制的に効かせるようにしないとダメでした
<style type="text/css">
    .Differences {
        width:100% !important;
        border-collapse:collapse !important;
        border-spacing:0 !important;
        empty-cells:show !important;
        word-break:break-all;
    }
    .Differences thead th {
        text-align:left !important;
        border-bottom:1px solid #000 !important;
        background:#aaa !important;
        color:#000 !important;
        padding:4px !important;
        word-break:break-all;
    }
    .Differences tbody th {
        text-align:right !important;
        background:#ccc !important;
        width:4em !important;
        padding:1px 2px !important;
        border-right:1px solid #000 !important;
        vertical-align:top !important;
        font-size:13px !important;
        word-break:break-all;
    }
    .Differences td {
        padding:1px 2px !important;
        font-family:Consolas, monospace !important;
        font-size:13px !important;
        word-break:break-all;
    }
    .DifferencesSideBySide .ChangeInsert td.Left {
        background:#dfd !important;
    }
    .DifferencesSideBySide .ChangeInsert td.Right {
        background:#cfc !important;
    }
    .DifferencesSideBySide .ChangeDelete td.Left {
        background:#f88 !important;
    }
    .DifferencesSideBySide .ChangeDelete td.Right {
        background:#faa !important;
    }
    .DifferencesSideBySide .ChangeReplace .Left {
        background:#fe9 !important;
    }
    .DifferencesSideBySide .ChangeReplace .Right {
        background:#fd8 !important;
    }
    .Differences ins, .Differences del {
        text-decoration:none !important;
    }
    .DifferencesSideBySide .ChangeReplace ins, .DifferencesSideBySide .ChangeReplace del {
        background:#fc0 !important;
    }
    .Differences .Skipped {
        background:#f7f7f7 !important;
    }
    .DifferencesInline .ChangeReplace .Left,
    .DifferencesInline .ChangeDelete .Left {
        background:#fdd !important;
    }
    .DifferencesInline .ChangeReplace .Right,
    .DifferencesInline .ChangeInsert .Right {
        background:#dfd !important;
    }
    .DifferencesInline .ChangeReplace ins {
        background:#9e9 !important;
    }
    .DifferencesInline .ChangeReplace del {
        background:#e99 !important;
    }
    pre {
        width:100% !important;
        overflow:auto !important;
        word-break:break-all;
    }
</style>
<div class="panel panel-success">
    <div class="panel-heading">差分チェック</div>
    <div class="panel-body" style="margin:0;padding:1px;">
        @if(!empty($diff_data))
            <table class='table table-bordered small' style="margin:0;padding:0;">
                @foreach($diff_data as $diff_key => $tmp)
                    @foreach($tmp as $table_name => $diff_html)
                        <tr>
                            <th class="bg-warning">{{ $conn_list[$diff_key] }}：{{ $table_name }}</th>
                        </tr>
                        <tr>
                            <td>{!! nl2br($diff_html) !!}</td>
                        </tr>
                    @endforeach
                @endforeach
            </table>
        @else
            <div style="margin:10px 15px;color:#0d3625;">差分はありません。</div>
        @endif
    </div>
</div>
{!! Form::open(['url' => '[url]', 'method' => 'post', 'name' => 'form']) !!}
<div class="row">
    <div class="form-group">
        <div class='col-sm-4'>
            {!! Form::label('execute_conn_list[]', '環境:', ['class' => 'text-left']) !!}
        </div>
    </div>
</div>
<div class="row">
    <div class="form-group">
        @foreach ($db_conn_list as $conn_value => $db_conn_name)
            <div class='col-sm-4'>
                {!! Form::checkbox('execute_conn_list[]', $conn_value, in_array($conn_value, $execute_conn_list ?? [])); !!}
                {{ $db_conn_name }}
            </div>
        @endforeach
    </div>
</div>
<div class="row">
    <div class="form-group">
        <div class='col-md-8'>
            {!! Form::label('sql', 'SQL:', ['class' => 'text-left']) !!}
            {!! Form::textarea('sql', $sql, ['class' => 'form-control', 'rows' => '12', 'readonly' => 'readonly']) !!}
        </div>
    </div>
</div>
<div class="row" style="margin-top:10px;">
    <div class='col-md-3'>
        {!! Form::submit('実行', ['class' => 'btn btn-primary']) !!}
        {!! Form::button('戻る', ['class' => 'btn btn-gray', 'onclick' => 'btn_back()']) !!}
    </div>
</div>
{!! Form::hidden('sql_exec', 1) !!}
{!! Form::hidden('file_org_name', $file_org_name) !!}
{!! Form::close() !!}

<script type="text/javascript">
    function btn_back() {
        location.href = '{!! url("[url]") !!}';
    }
</script>
```

</div></details>

※この後XLSXから反映したいシートを選択しSQL生成して実行するツールを作った。
PHPでXLSXを操作するのに以下のライブラリを使いました。

```
PHPOffice/PhpSpreadsheet
https://github.com/PHPOffice/PhpSpreadsheet
```

100シート以上操作したらハングアップする時があったので大量に処理する場合はバッチにした方がよさそう。
今回は多くても1ファイル20シートぐらいだったのでバッチ化せず。

### 差分表示するサンプル
複数の開発環境を選択して実行できるようになっています。
![master_data_sql_diff_sql.png](https://qiita-image-store.s3.amazonaws.com/0/178991/cb3deec7-cac8-c307-ee26-c8444ff5ac66.png)

```sql:テスト用のテーブル
CREATE TABLE `MASTER_DATA_TEST` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `test1` VARCHAR(255) NOT NULL,
  `test2` VARCHAR(255) NOT NULL,
  `test3` INT(11) NOT NULL,
  PRIMARY KEY (`id`));
```

```sql:基本データ
INSERT INTO MASTER_DATA_TEST VALUES
('TEST_DATA_ID_0001','テストデータ0001',1),
('TEST_DATA_ID_0002','テストデータ0002',2),
('TEST_DATA_ID_0003','テストデータ0003',3),
('TEST_DATA_ID_0004','テストデータ0004',4),
('TEST_DATA_ID_0005','テストデータ0005',5),
('TEST_DATA_ID_0006','テストデータ0006',6),
('TEST_DATA_ID_0007','テストデータ0007',7),
('TEST_DATA_ID_0008','テストデータ0008',8),
('TEST_DATA_ID_0009','テストデータ0009',9),
('TEST_DATA_ID_0010','テストデータ0010',10),
('TEST_DATA_ID_0011','テストデータ0011',11),
('TEST_DATA_ID_0012','テストデータ0012',12),
('TEST_DATA_ID_0013','テストデータ0013',13),
('TEST_DATA_ID_0014','テストデータ0014',14),
('TEST_DATA_ID_0015','テストデータ0015',15),
('TEST_DATA_ID_0016','テストデータ0016',16),
('TEST_DATA_ID_0017','テストデータ0017',17),
('TEST_DATA_ID_0018','テストデータ0018',18),
('TEST_DATA_ID_0019','テストデータ0019',19),
('TEST_DATA_ID_0020','テストデータ0020',20);
```

以下のように変更して実行しようとすると差分を検知して表示します。（添付画像）

```sql:データを変更したSQL
DELETE FROM `MASTER_DATA_TEST`;
INSERT INTO `MASTER_DATA_TEST`(`test1`, `test2`, `test3`) VALUES
('TEST_DATA_ID_0001','テストデータ0001',1),
('TEST_DATA_ID_0002','テストデータ0002',2),
('TEST_DATA_ID_0003','テストデータ0003',3),
('TEST_DATA_ID_0004','テストデータ0004',4),
('TEST_DATA_ID_0005','テストデータ0005',5),
('TEST_DATA_ID_0006','テストデータ0006',6),
('TEST_DATA_ID_0008','テストデータ0008',8),
('TEST_DATA_ID_0009','テストデータ0009',90),
('TEST_DATA_ID_0010','テストデータ0010',10),
('TEST_DATA_ID_0011','テストデータ0011',11),
('TEST_DATA_ID_0013','テストデータ0013',13),
('TEST_DATA_ID_0022','テストデータ0021',22),
('TEST_DATA_ID_0014','テストデータ0014',14),
('TEST_DATA_ID_0015','テストデータ0015',15),
('TEST_DATA_ID_0016','テストデータ0016',16),
('TEST_DATA_ID_0018','テストデータ0018',18),
('TEST_DATA_ID_0019','テストデータ0019',19),
('TEST_DATA_ID_0020','テストデータ0020',20),
('TEST_DATA_ID_0021','テストデータ0021',21);
```

![master_data_sql_diff_check.png](https://qiita-image-store.s3.amazonaws.com/0/178991/faf652cd-93a9-d723-d609-c058d0bdb37c.png)
SQLが間違っていればエラー画面を表示（添付画像）
とりあえずエラーメッセージの1行目だけ表示していますが全文表示でよいかも…。
SQLの実行エラーなのでプログラマ以外でも分かるような内容にするのが難しい。

```sql:カンマが抜けているSQL
INSERT INTO MASTER_DATA_TEST VALUES
('TEST_DATA_ID_0001','テストデータ0001',1),
('TEST_DATA_ID_0002','テストデータ0002',2),
('TEST_DATA_ID_0003','テストデータ0003',3),
('TEST_DATA_ID_0004','テストデータ0004',4),
('TEST_DATA_ID_0005','テストデータ0005',5),
('TEST_DATA_ID_0006','テストデータ0006',6),
('TEST_DATA_ID_0007','テストデータ0007',7),
('TEST_DATA_ID_0008','テストデータ0008',8),
('TEST_DATA_ID_0009','テストデータ0009',9),
('TEST_DATA_ID_0010','テストデータ0010',10),
('TEST_DATA_ID_0011','テストデータ0011',11),
('TEST_DATA_ID_0012','テストデータ0012',12),
('TEST_DATA_ID_0013','テストデータ0013',13),
('TEST_DATA_ID_0014','テストデータ0014',14)
('TEST_DATA_ID_0020','テストデータ0020',20);
```

![master_data_sql_diff_execute_error.png](https://qiita-image-store.s3.amazonaws.com/0/178991/02383004-0348-77ee-52fc-b81cd212cf42.png)
いろいろな人がシートを更新するので意図せずデータ反映してしまうことが多々ありましたが、
差分表示することで事前確認できるのでだいぶマシになりました。

なおSQL実行後に自動でマスターデータチェックをして結果を表示してます。（添付画像）
![master_data_sql_diff_success.png](https://qiita-image-store.s3.amazonaws.com/0/178991/eed0be19-19a9-541f-1ceb-c4aaf3ee5720.png)
これは正常な時の画面です。
↓エラー表示については以下を参照されたし。

# マスターデータのチェック
マスターデータのチェックは自動化が難しいので個別にチェック処理を書いています。
メッセージと一緒に表示したいカラムはテーブルごとに変わるので可変できるようにしてあります。
SQL実行時に自動でマスターデータのチェックを行い結果を表示します。
このチェックは元々別画面で使えるようにしていたが、使ってくれない人が多かったため自動チェックで強制表示するようにしました。
（ある程度は強制しないとダメなんだなー……）

```php:マスターデータをチェックしてエラーを出力
/**
 * マスターデータをチェックしてエラーメッセージを配列で返す 
 *
 * @return array $check_list
 * 		$check_list = [
 *			'テーブル名' => [
 *				'エラーレベル(warning or danger)' => [
 *					[
 *						'カラム名' => 'データ',
 *						'カラム名' => 'データ',
 *						…
 *						'message' => 'メッセージ',
 *					],
 *				],
 *			],
 *		]
*/
$check_list = MasterData::check(); //この中にチェック処理が入ってます。
```

```html:マスターデータチェックのエラーを表示
@if(!empty($check_list))
    @foreach ($check_list as $check_table => $c)
        <div class="panel panel-primary small" style="margin:5px 0 0 0;">
            <div class="panel-heading">{{ $check_table }}</div>
            <div class="panel-body" style="margin:0;padding:1px;">
                <table class='table table-bordered' style="margin:0;padding:0;font-size:inherit;">
                    @foreach ($c as $level => $c2)
                        @foreach ($c2 as $c3)
                            @if($loop->first)
                                <tr class="bg-info">
                                    @foreach ($c3 as $k => $v)
                                        <td style="text-align:center;">{{ $k }}</td>
                                    @endforeach
                                </tr>
                            @endif
                            <tr>
                                @foreach ($c3 as $v)
                                    <td class="bg-{{ $level }}" style="word-break:break-all;">{!! nl2br($v) !!}</td>
                                @endforeach
                            </tr>
                        @endforeach
                    @endforeach
                </table>
            </div>
        </div>
    @endforeach
@else
    <div class="panel panel-success small" style="margin-top:10px;">
        <div class="panel-heading"></div>
        <div class="panel-body" style="margin:0;padding:1px;">
            <div style="margin:10px 15px;color:#0d3625;">エラーはありません。</div>
        </div>
    </div>
@endif
```

# パラメータは自動出力できるように
ガチャの確率表示や新カードの紹介などを行うページを作る際に手作業でHTMLを組むような状況だったので
特定のルールでタグを書くとマスターデータから取得したパラメータを組み込んだHTMLを出力するようにしました。
編集者がDreamweaverを使っていたのでdivタグに独自の属性を入れるという方式に。
これでパラメータ変更による再編集の手間がなくなりました。
手作業による更新ミスも起こりづらくなります。

```html:変換元HTML（$html_data）
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no" />
    <meta name="format-detection" content="telephone=no,email=no,address=no" />
    <!-- 無いとPHPの関数でエラーになってしまうのでHTML5でも入れる -->
    <meta http-equiv="content-type" content="text/html; charset=UTF-8" />
</head>
<body>
<div id="container">
	<!-- カードタイプAの情報を出力 -->
	<div　data_type="card_a" data_master_id="xxxxxx"></div>
	<div　data_type="card_a" data_master_id="xxxxxx"></div>
	<div　data_type="card_a" data_master_id="xxxxxx"></div>
	<!-- カードタイプBの情報を出力 -->
	<div　data_type="card_b" data_master_id="xxxxxx"></div>
	<div　data_type="card_b" data_master_id="xxxxxx"></div>
	<div　data_type="card_b" data_master_id="xxxxxx"></div>
	<!-- ガチャの排出確率を出力 -->
	<div data_type="gacha_a" data_master_id="xxxxxx"></div>
	<div data_type="gacha_b" data_master_id="xxxxxx"></div>
</div>
</body>
</html>
```
マスターIDをもとにDBからデータ取得しHTMLを組む処理を書きます。

```php:特定のタグにパラメータを出力
libxml_use_internal_errors(true);
$dom = new \DOMDocument();
$dom->loadHTML($html_data, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
// HTML置換
$dom_list = $dom->getElementsByTagName('div');
if (!empty($dom_list) && $dom_list->length > 0) {
    for ($i = 0; $i < $dom_list->length; ++$i) {
        $dom_item = $dom_list->item($i);
        if ($dom_item->getAttribute('data_type') !== '') {
            $dom_item->setAttribute('style', 'margin:0;padding:0;border:0;');
            switch ($dom_item->getAttribute('data_type')) {
                // カード
                case 'card_a':
                    $master_id = $dom_item->getAttribute('data_master_id');
                    $html_code = '[ここにカードのHTMLを生成する処理を書く]';
                    /**
                     * 親タグを1個にしないとエラーになってしまうので強制的にタグで囲んでいる
                     * 元々1個なら囲む必要はないはず
                     */
                    $node = $dom->importNode(dom_import_simplexml(simplexml_load_string('<span style="margin:0;padding:0;border:0;">' . $html_code . '</span>')), true);
                    $dom_item->appendChild($node);
                    $dom_item->removeAttribute('data_type');
                    $dom_item->removeAttribute('data_master_id');
                    break;
                case 'card_b':
                    $master_id = $dom_item->getAttribute('data_master_id');
                    $html_code = '[ここにカードのHTMLを生成する処理を書く]';
                    $node = $dom->importNode(dom_import_simplexml(simplexml_load_string('<span style="margin:0;padding:0;border:0;">' . $html_code . '</span>')), true);
                    $dom_item->appendChild($node);
                    $dom_item->removeAttribute('data_type');
                    $dom_item->removeAttribute('data_master_id');
                    break;
                // ガチャ
                case 'gacha_a':
                    $html_code = '[ここにガチャのHTMLを生成する処理を書く]';
                    $node = $dom->importNode(dom_import_simplexml(simplexml_load_string('<span style="margin:0;padding:0;border:0;">' . $html_code . '</span>')), true);
                    $dom_item->appendChild($node);
                    $dom_item->removeAttribute('data_type');
                    $dom_item->removeAttribute('data_master_id');
                    break;
                case 'gacha_b':
                    $html_code = '[ここにガチャのHTMLを生成する処理を書く]';
                    $node = $dom->importNode(dom_import_simplexml(simplexml_load_string('<span style="margin:0;padding:0;border:0;">' . $html_code . '</span>')), true);
                    $dom_item->appendChild($node);
                    $dom_item->removeAttribute('data_type');
                    $dom_item->removeAttribute('data_master_id');
                    break;
            }
        }
    }
}
libxml_clear_errors();
$output_html = $dom->saveHTML();
```

# 小数点第○位までの確率を表示する時は要注意
確率は小数点5位まで表示していました。
PHPで普通に計算すると誤差があって正しくならない時がありました。
[浮動小数点数の精度（PHPマニュアル）](http://php.net/manual/ja/language.types.float.php)

`sprintf`で切り捨てようとしても四捨五入したような結果になったりするのでダメでした。
取り急ぎ文字列処理することで逃げました…。

```php
function gacha_rate($probability, $probability_total, $length=5)
{
    $probability = $probability / $probability_total * 100;
    if (strpos($probability, '.') !== false) {
        $split = explode('.', $probability);
    } else {
        $split = [
            0 => $probability,
            1 => "0",
        ];
    }
    return ($split[0] . '.' . str_pad(substr($split[1], 0, $length), $length, 0));
}
```

ちゃんと計算するなら数学関数を使ったこちらのコードがよいでしょう。
[任意精度数学関（PHPマニュアル）](http://php.net/manual/ja/book.bc.php)

```php
function gacha_rate($probability, $probability_total, $length=5)
{
    return bcmul(bcdiv($probability, $probability_total, $length+2), 100, $length);
}
```
この計算結果をそのまま表示するように組みましょう。
例えば`sprintf`を通して出力しようとするとダメです。

# 編集後記
かなり文字を書いた気がするのだけど、いろいろと言うほど紹介できてない気がするな…アレ？オカシイナ。
もしこの記事が役に立つ人がいれば幸いです。

でも記事は自分のために書くのが一番だと思います。
また勉強になった。