ORM で JSON カラム更新時の注意点


バッチ処理で JSON カラムの連想配列内部を書き換えて更新するような処理を回していたら、見事に全く更新されておらず、呆然としてしまったので、原因を調査して対応したことをメモ。

`CakePHP 3.x` で発生しただけの話で、他のフレームワークの ORM でも同様の事象が発生するかどうかは不明ですが、もし、似たような事象が起こり、この記事に辿り着き、同じ観点で調査対象を絞れたりすることを期待し、少し大げさなタイトルを付けてしまいましたが、ご了承願います。

----

### 原因

JSON カラムで作られた property の連想配列内部が更新されても、該当する model entity の property が更新された (汚された) と検知できておらず、 save メソッドを実行しても更新処理が skip されてしまっていた。

----

### 対応

JSON カラムで作られた property の連想配列を更新する場合、該当する model entity の property が更新された (汚された) ことを save 前に通知してやる。

----

### 補足説明

`CakePHP 3.x` で発生した内容を例として説明する。

下記のようなスキーマのテーブルで Model を作成する。

```SQL
CREATE TABLE IF NOT EXISTS `user` (
    `id`              BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    `name`            VARCHAR(255) NOT NULL,
    `code`            VARCHAR(255) CHARACTER SET ASCII COLLATE ASCII_BIN NOT NULL,
    `role`            VARCHAR(255) CHARACTER SET ASCII COLLATE ASCII_BIN DEFAULT NULL,
    `data`            JSON,
    `created_at`      TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `updated_at`      TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    `deleted_at`      TIMESTAMP NULL DEFAULT NULL,
    UNIQUE KEY `code` (`code`),
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='...' ;
```

下記のような処理で User を作成する。

```php
    public function create(string $code, string $name)
    {
        $this->loadModel('User');

        $entity = $this->User->newEntity();
        $entity->code = $code;
        $entity->name = $name;
        $entity->data = [
            'permission' => [
                'read'  => false,
                'write' => false,
                // ...
            ],
            // ...
        ];
        $this->User->save($entity);

        // 確認
        $row = $this->User->find()->where(['code' => $code])->first()->toArray();
        $this->out(json_encode($row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
    }
```

```JSON
{
    "id": 1,
    "name": "Ukyoo!",
    "code": "ukyooo",
    "role": NULL,
    "data": {
        "permission": {
            "read": false,
            "write": false,
            ...
        },
        ...
    },
    ...
}
```

下記のような処理で連想配列 data 内部を書き換えて保存する。

```php
    public function appointSuperUser($code)
    {
        $this->loadModel('User');
        $entity = $this->User->find()->where(['code' => $code])->first();

        // 役割 設定
        $entity->role = 'SuperUser';

        // 権限 設定
        $entity->data['permission']['read'] = true;
        $entity->data['permission']['write'] = true;

        // 保存
        $this->User->save($entity);

        // 確認
        $row = $this->User->find()->where(['code' => $code])->first()->toArray();
        $this->out(json_encode($row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
    }
```

```JSON
{
    "id": 1,
    "name": "Ukyoo!",
    "code": "ukyooo",
    "role": "SuperUser",    // role は更新されるが、
    "data": {
        "permission": {
            "read": false,  // data の連想配列内の情報が更新されない。
            "write": false,
            ...
        },
        ...
    },
    ...
}
```

すると、上記のように連想配列 data 内部の値が更新されていない。

原因としては model entity に連想配列 data 内部が書き換わった程度では data という property が更新されたと検知されず、 save 時に update を skip されてしまう為であった。

とりあえずの対応として（もっと良い方法はあるかもしれないが）、
下記のように data 自身を代入して model entity に更新されたことが検知できるようにする。

```php
    public function appointSuperUser($code)
    {
        $this->loadModel('User');
        $entity = $this->User->find()->where(['code' => $code])->first();

        // 役割 設定
        $entity->role = 'SuperUser';

        // 権限 設定
        $entity->data['permission']['read'] = true;
        $entity->data['permission']['write'] = true;

        $this->out(sprintf('data %s dirty', $entity->isDirty('data') ? 'is' : 'is not'));
        // > data is not dirty
        
        // 無理矢理 data 自身を代入して property を更新
        $entity->data = $entity->data;
        
        $this->out(sprintf('data %s dirty', $entity->isDirty('data') ? 'is' : 'is not'));
        // > data is dirty

        $this->User->save($entity);

        // 確認
        $row = $this->User->find()->where(['code' => $code])->first()->toArray();
        $this->out(json_encode($row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
    }
```

```JSON
{
    "id": 1,
    "name": "Ukyoo!",
    "code": "ukyooo",
    "role": "SuperUser",
    "data": {
        "permission": {
            "read": true,
            "write": true,
            ...
        },
        ...
    },
    ...
}
```