独自の型(Type)を作ってJsonTypeを拡張する話

この記事は [CakePHP Advent Calendar 2019](https://qiita.com/advent-calendar/2019/cakephp) 16日目の記事です。

## やりたいこと
jsonを保存しているカラムにデータを保存するときに、Unicodeエスケープしないようにしたい[^1]

## 前提
本記事は CakePHP3の`Type`とは何なのか、理解していることを前提とします。
よく分からない、という方は以下の記事がおすすめなのでぜひ読んで理解してみてください。

- [【CakePHP3】 Type の話](https://qiita.com/o0h/items/59772ee4e8eb4c91b26f)
- [CakePHP3のORMの中核を担う「Entity」とは何か 〜CakePHP2ユーザー向けに〜](https://tech.connehito.com/entry/cakephp3-orm-entity)

本記事に関連する箇所だけまとめると以下のとおりです。

- CakePHP3では `Type` と呼ばれる仕組みを使って、各カラムの値をアプリケーション内でどのように扱うかを決定している
- データベースから取得するとき、またその逆に保存するときの「データの加工」は `Type` の責務となっている
- stringやinteger、jsonなど基本的な型の `Type` は[デフォルトで提供されている](https://book.cakephp.org/3/ja/orm/database-basics.html#id8)

## デフォルトのJsonTypeだとUnicodeエスケープされる
前提として、環境はPHP7.2、CakePHP 3.8.7、MySQL5.7を使用します。
また、テーブルスキーマは以下の通りです（jsonを保存するカラムは`body_json`）。[^2]

```sql
CREATE TABLE `posts` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `body` varchar(255) NOT NULL,
  `body_json` text NOT NULL,
  `created` datetime NOT NULL,
  `modified` datetime NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4;
```

まずはカラムとTypeの紐付けをしましょう。
PostsTableで以下のように指定します。

```php
    protected function _initializeSchema(TableSchema $schema)
    {
        $schema->setColumnType('body_json', 'json'); // body_jsonカラムをJsonTypeに設定
        return $schema;
    }
```

この環境でデータを保存してみます。
すると以下のようにbody_jsonカラムにはUnicodeエスケープされた状態で保存されてしまいました。

![_MySQL_5_7_28__127_0_0_1_qa_app_qa_app_posts.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6159/c792e838-0cb7-8704-a63a-bbad4d5d4154.png)

これは、デフォルトのJsonTypeのjson_encode()処理が以下のようにオプションが未指定になっているためです。あえてエスケープをしないためには `JSON_UNESCAPED_UNICODE` を第２引数で指定する必要があります。

```php
    public function toDatabase($value, Driver $driver)
    {
        if (is_resource($value)) {
            throw new InvalidArgumentException('Cannot convert a resource value to JSON');
        }
        return json_encode($value);
    }
```

https://github.com/cakephp/cakephp/blob/3.8.7/src/Database/Type/JsonType.php#L67

というわけで、JsonTypeの `json_encode()` 部分でオプションを指定するために、JsonTypeを拡張した独自の型を作ってアプリケーション内で利用してみましょう。

## JsonTypeを継承してUnicodeエスケープしない独自のJsonTypeを定義する
独自の型を定義する方法は[独自の型を作成する](https://book.cakephp.org/3/ja/orm/database-basics.html#adding-custom-database-types)に簡単な説明が書いてあるのでこの流れにそって作っていきます。

### 1.TypeInterfaceを実装したクラスを用意する
独自の型を定義するには[Cake\Database\TypeInterface](https://github.com/cakephp/cakephp/blob/3.8.7/src/Database/TypeInterface.php)を実装したクラスを作成する必要があります。
実運用では[Cake\Database\Type](https://github.com/cakephp/cakephp/blob/3.8.7/src/Database/Type.php)を継承したクラスを作るのが一番簡単でしょう。
今回は[JsonType](https://github.com/cakephp/cakephp/blob/3.8.7/src/Database/Type/JsonType.php)の一部分の処理を変えるだけなので、更に手を抜いて [JsonType](https://github.com/cakephp/cakephp/blob/3.8.7/src/Database/Type/JsonType.php) を継承して独自のJsonTypeを作っていきます。

独自のJsonTypeでやることは[この行](https://github.com/cakephp/cakephp/blob/3.8.7/src/Database/Type/JsonType.php#L67)でオプションに`JSON_UNESCAPED_UNICODE`を指定するだけです。

早速、`src/Database/Type/` ディレクトリを作成し、ファイルを作成します。名前は `UnescapedUnicodeJsonType` としましょう。コードは以下のとおりです。

```php
<?php

namespace App\Database\Type;

use Cake\Database\Driver;
use Cake\Database\Type\JsonType;
use InvalidArgumentException;

class UnescapedUnicodeJsonType extends JsonType
{
    protected $_name = 'unescaped_unicode_json';

    /**
     * 入力された値をJSON文字列に変換する
     *
     * @param mixed $value エンコードする値. resource型以外を指定する
     * @param Driver $driver データベースドライバ
     * @return string|null JSONエンコードされた文字列、またはnull
     */
    public function toDatabase($value, Driver $driver)
    {
        if (is_resource($value)) {
            throw new InvalidArgumentException('resource型はJSONに変換できません');
        }

        // Unicode文字をエスケープしないためオプションを指定
        return json_encode($value, JSON_UNESCAPED_UNICODE);
    }
}
```

`toDatabase()`内の処理としてはオプションを指定した以外は親クラスと同じです。
`$_name`は型をアプリケーション内から特定するための名前です。coreのコードを見ても複数単語をつなぐパターンがなかったのでここではスネークケースで記述しています。

独自に拡張した部分についてはテストも作っておきましょう。こちらも[親クラスのテストコード](https://github.com/cakephp/cakephp/blob/3.8.7/tests/TestCase/Database/Type/JsonTypeTest.php#L90)を参考に実装してください。

```php
    /**
     * toDatabase()のテスト
     *
     * @return void
     */
    public function testToDatabase()
    {
        /* (略) */
        $this->assertSame(
            json_encode(['a' => 'b'], JSON_UNESCAPED_UNICODE),
            $this->type->toDatabase(['a' => 'b'], $this->driver),
            '配列の変形が適切にできていない'
        );
        $this->assertSame(
            json_encode(['キー' => 'バリュー'], JSON_UNESCAPED_UNICODE),
            $this->type->toDatabase(['キー' => 'バリュー'], $this->driver),
            'マルチバイト文字を含む配列の変形が適切にできていない'
        );
    }
```

### 2.アプリケーションから使えるようにする
新しい型を作成したら、"型マッピング"に追加する必要があります。ここでは、bootstrap.phpに以下の処理を追加します。

```php
Type::setMap(array_merge(
    Type::getMap(),
    ['unescaped_unicode_json' => UnescapedUnicodeJsonType::class] // ここで追加したいTypeを指定
));
```

最後に、このTypeと利用したいカラムとを紐付けます。ここではPostsTable.phpで紐付ける処理を追加します。

```php
    protected function _initializeSchema(TableSchema $schema)
    {
        parent::_initializeSchema($schema);
        $schema->setColumnType('body_json', 'unescaped_unicode_json');

        return $schema;
    }
```

### 3.保存して動作確認
それではここまでの内容を反映させて保存してみましょう。

![_MySQL_5_7_28__127_0_0_1_qa_app_qa_app_posts.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6159/102e4557-e82a-bf7d-682e-3891741c390b.png)

無事に`body_json`カラムがUnicodeエスケープされていないことが確認できました！


## おわりに
今回は物凄くシンプルな例でしたが、アプリケーションによって独自のTypeを定義してあげることでコードの見通しがグンと良くなることがあります。ぜひTypeを使ってより良いコードを書いてみてください！


[^1]: そもそも、カラムの型がJSON型(MySQLなら5.7から使える)であれば、本記事の対応をしなくてもDBに保存される値はUnicodeエスケープされないようでした。ドキュメントを確認したのですが、なぜそうなるのかはっきりとした仕様が分からなかったのでご存知の方がいましたら共有していただけると嬉しいです!
[^2]: が、本記事では*特殊な事情*によりText型のカラムにJSONを保存しているケースのために対応を記載します。