CakePHP3 ORM → CakePHP2 変換を作って挫折した話


[CakePHP2→3への段階的なバージョンアップ](https://engineer.blog.lancers.jp/2019/09/cakephp2to3_start/)に挑戦しています。

その際、一番のハードルになるであろうModelのORM周りの移行方法を検討していました。

昨年のAdvent Calendarでの @kunit さんの記事「[CakePHP2 から CakePHP3 ORM を使ってみる](https://qiita.com/kunit/items/f00d232baead413ba223)」はとても興味深い試みで、可能性を感じさせるものでした。

今回、このアプローチを応用し、段階的な移行ができるか挑戦してみました。

※結果、挫折して別なアプローチに切り替えたのですが、CakePHP2からCakePHP3を呼び出す処理など、参考にさせていただいた部分は非常に多かったです。

以下、その顛末を書きたいと思います。

# CakePHP2とCakePHP3を共存

CakePHP2とCakePHP3を共存します。

CakePHP3をcomposerでインストールします。
CakePHP2はcomposer管理から外し、ローカルのVenderディレクトリでGit管理します。
（CakePHP2本体のソースを直接いじることを想定し、Git管理下に置きます）

https://speakerdeck.com/ykanazawa/consider-a-smooth-upgrade-to-cakephp-3?slide=24
<script async class="speakerdeck-embed" data-slide="24" data-id="636135f2c31947b791552abf77d6cae5" data-ratio="1.33333333333333" src="//speakerdeck.com/assets/embed.js"></script>

# CakePHP2からCakePHP3のORMを呼び出す

## CakePHP2からCakePHP3を呼び出す設定

CakePHP2のbootstrap.phpに以下の記述を追加します

```app/Config/bootstrap.php
define('CONFIG_CAKEPHP3', __DIR__ . '/../../config/');
$dotenv = new \josegonzalez\Dotenv\Loader([CONFIG_CAKEPHP3 . '.env']);
$dotenv->parse()
    ->putenv()
    ->toEnv()
    ->toServer();
try {
    \Cake\Core\Configure::config(
        'default',
        new \Cake\Core\Configure\Engine\PhpConfig(CONFIG_CAKEPHP3)
    );
    \Cake\Core\Configure::load('app', 'default', false);
} catch (\Exception $e) {
    exit($e->getMessage() . "\n");
}
\Cake\Datasource\ConnectionManager::setConfig(
    'default',
    \Cake\Core\Configure::read('Datasources.default')
);
\Cake\Datasource\ConnectionManager::setConfig(
    'slave',
    \Cake\Core\Configure::read('Datasources.slave')
);
\Cake\Datasource\ConnectionManager::setConfig(
    'test',
    \Cake\Core\Configure::read('Datasources.test')
);
\Cake\Cache\Cache::setConfig(
    '_cake_model_',
    \Cake\Core\Configure::read('Cache._cake_model_')
);
\Cake\I18n\I18n::setLocale(\Cake\Core\Configure::read('App.defaultLocale'));
```

## CakePHP2のModelからCakePHP3のTableを呼び出す

例として、以下のCity.phpというModelを例に実装してみます。

```app/Model/City.php
<?php
class City extends AppModel
{
    public $name = 'City';
    public $validate = [
        'id' => ['numeric'],
    ];
 
    public function getName($cityId)
    {
        $prefectural = $this->findById($prefecturalId);
        if (isset($prefectural['Prefectural']['name']) {
            return null;
        }
        return $prefectural['Prefectural']['name'];
    }
}
```

CakePHP2のController.phpに手を入れます。
modelを読み込むタイミングでCakePHP3のmodelが存在していた場合、
CakePHP3のtablesを優先的に呼び出します。

```app/Vendor/cakephp/cakephp/lib/Cake/Controller/Controller.php
+		// CakePHP3のTablesが定義されていれば優先的に割り当てる
+		$modelClass3 = Inflector::pluralize($modelClass); // City -> Cities
+		$this->{$modelClass} = Cake\ORM\TableRegistry::getTableLocator()->get($modelClass3);
+		if ($this->{$modelClass} instanceof App\Model\Table) {
+			return true;
+		}

		$this->{$modelClass} = ClassRegistry::init(array(
			'class' => $plugin . $modelClass, 'alias' => $modelClass, 'id' => $id
		));
```

# CakePHP3のfindをCakePHP2に変換する

Tableクラスを継承したLTableクラスを作成します。
LTableクラス内でfind関数をオーバーライドします。
ここに、CakePHP3のオブジェクトをCakePHP2の配列に変換する処理を入れます。

```src/Model/Table/LTable.php
<?php
namespace App\Model\Table;
use Cake\ORM\Table;
class LTable extends Table
{
    public function find($type = 'first', $options = [])
    {
        $caller = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 1)[0]['file'];
        if (strpos($caller, 'cake28/') === false) {
            // CakePHP3の場合
            return parent::find($type, $options);
        }
        // CakePHP2の場合
        switch ($type) {
            case 'count':
                $query= parent::find();
                if (!empty($options['joins'])) {
                    $query->join($options['joins']);
                }
                if (!empty($options['conditions'])) {
                    $query->where($options['conditions']);
                }
                return $query->{$type}();
            case 'list':
                $keyField = 'id';
                $valueField = 'name';
                if (!empty($options['fields'])) {
                    $fields = $options['fields'];
                    // idカラムを抜く
                    $fields = array_diff($fields, ['id']);
                    $fields = array_values($fields);
                    $valueField = empty($fields) ? 'id' : $fields;
                }
                $query = parent::find($type, [
                    'keyField' => $keyField,
                    'valueField' => $valueField,
                ]);
                if (!empty($options['joins'])) {
                    $query->join($options['joins']);
                }
                if (!empty($options['conditions'])) {
                    $query->where($options['conditions']);
                }
                if (!empty($options['group'])) {
                    $query->group($options['group']);
                }
                if (!empty($options['order'])) {
                    $query->order($options['order']);
                }
                $result = $query->toArray();
                foreach ($result as $key => $value) {
                    if ($value instanceof \Cake\I18n\Time) {
                        // \Cake\I18n\Timeを日付文字列に変換
                        $value = $value->i18nFormat('YYYY-MM-dd HH:mm:ss');
                        $result[$key] = $value;
                    }
                }
                return $result;
            default:
                $query= parent::find();
                if (!empty($options['fields'])) {
                    //$fields = $this->toCake3Aggregation($options['fields']);
                    $fields = [];
                    foreach ($options['fields'] as $key => $value) {
                        if (preg_match('/COUNT *\(.*\)/i', $value)) {
                            $col = preg_replace('/.*COUNT.*\((.*)\)/i', '$1', $value);
                            $fields[$key] = $query->func()->count($col);
                        } else {
                            $fields[$key] = $value;
                        }
                    }
                    $query->select($fields);
                }
                if (!empty($options['joins'])) {
                    $query->join($options['joins']);
                }
                if (!empty($options['conditions'])) {
                    $query->where($options['conditions']);
                }
                if (!empty($options['group'])) {
                    $query->group($options['group']);
                }
                if (!empty($options['order'])) {
                    $query->order($options['order']);
                }
                $result = $query->{$type}();
                return $this->toCake2Array($result);
        }
    }

    /**
     * CakePHP3のクエリ結果をCakePHP2のfindの結果形式に変換する
     *
     * @param \Cake\ORM\Entity|\Cake\ORM\Query $entity
     * @param string $alias
     * @return array
     */
    private function toCake2Array($entity, string $alias = null) : array
    {
        $result = [];
        if ($entity instanceof \Cake\Orm\ResultSet) {
            foreach ($entity as $row) {
                $result[] = $this->toCake2Array($row);
            }
        } else {
            if ($alias === null) {
                // TODO: もっとスマートな方法がないのか？
                $alias = str_replace('App\\Model\\Entity\\', '', get_class($entity));
            }
            $result[$alias] = $entity->toArray();
        }
        $result = $this->toParallelArray($result);
        return $result;
    }

    /**
     * JOINしたfindの取得結果をテーブルに並列に並べる
     * （Cake3だとJOINしたテーブルが入れ子になってしまうため）
     *
     * 例：
     * $result['City']['id']
     * $result['City']['Prefectural']['id']
     * ↓
     * $result['City']['id']
     * $result['Prefectural']['id']
     *
     */
    private function toParallelArray($value) : array
    {
        $result = [];
        foreach ($value as $key1 => $value1) {
            foreach ($value1 as $key2 => $value2) {
                if (!is_numeric($key1) && !is_numeric($key2) && is_array($value2)) {
                    $result[$key2] = $value2;
                    continue;
                }
                if ($value2 instanceof \Cake\I18n\Time) {
                    // \Cake\I18n\Timeを日付文字列に変換
                    $value2 = $value2->i18nFormat('YYYY-MM-dd HH:mm:ss');
                }
                $result[$key1][$key2] = $value2;
            }
        }
        return $result;
    }
}
```

# 挫折

CakePHP3のfind関数をオーバーライドし、CakePHP2からそのfind関数を呼び出すことで、先んじてModelを移行する試みだったのですが、途中で断念しました。

対応しなければいけないSQLのパターンが多すぎるためです。

- InflectorでTable名→Model名変換
- CakePHP3のEntityをCakePHP2の配列に変換
- CakePHP3のDateTime型をフォーマット変換
- find(‘list’)、find(‘all’)の対応
- ORDER BYの対応

↑ここまでは対応していたのですが、

- GROUP BYの対応
- fieledのワイルドカード
- バーチャルフィールドなしの集計関数　
- HAVING区
- CASE WHENなどの分岐
- その他いろいろ

↑ここら辺も対応する必要があることがわかってきました。
これらすべてのクエリを全てを網羅した変換処理を書き続けるよりも、
別なアプローチの方が良いと思いました。

※この変換ロジックの実装時に書いていたUTを晒しておきます。
（途中で心が折れました）

```app/Test/Case/Model/LTableTest.php
<?php
use Test\App\Lib\LCakeTest;
/**
 *
 * CakePHP3移行後に廃棄するテスト
 *
 * CakePHP3のLTableのfindで取得したEntityが
 * CakePHP2の連想配列に正しく変換できているかテストする。
 *
 * @example
 * ./cake28/Console/cake l_test cake28/Test/Case/Model/LTableTest.php
 */
class LTableTestCase extends LCakeTest
{
    protected $useAllFixtures = false;
    protected $models = [
        'City'
    ];
    protected $cake2Model;
    protected $cake3Table;

    public $fixtures = [
        'app.city',
        'app.prefectural',
    ];

    public function setUp()
    {
        parent::setUp();
        $this->cake2Model = ClassRegistry::init('City');
        $this->cake3Table = Cake\ORM\TableRegistry::getTableLocator()->get('Cities');
        $this->cake3Table->setAlias('City');
        $connection = Cake\Datasource\ConnectionManager::get('test');
        $this->cake3Table->setConnection($connection);
    }

    public function testCake3Get()
    {
        $result = $this->cake2Model->findById(1101);
        $this->assertSame('札幌市', $result['City']['name']);
        $result = $this->cake3Table->get(1101);
        $this->assertSame('札幌市', $result['name']);
    }

    public function testFindAll()
    {
        $result2 = $this->cake2Model->find('all');
        $result3 = $this->cake3Table->find('all');
        $this->assertTrue($result2 == $result3);
        $result2 = $this->cake2Model->find('all', [
            'fields' => [
                'City.id',
                'City.prefectural_id',
                'City.name',
                'City.created',
                'City.modified',
                'Prefectural.id',
                'Prefectural.region_id',
                'Prefectural.name',
                'Prefectural.created',
                'Prefectural.modified',
            ],
            'joins' => [
                [
                    'type' => 'INNER',
                    'alias' => 'Prefectural',
                    'table' => 'prefecturals',
                    'conditions' => [
                        "City.prefectural_id = Prefectural.id",
                    ]
                ],
            ],
            'conditions' => [
                'City.id >' => 1101,
            ],
            'order' => ['City.id DESC']
        ]);
        $result3 = $this->cake3Table->find('all', [
            'fields' => [
                'City.id',
                'City.prefectural_id',
                'City.name',
                'City.created',
                'City.modified',
                'Prefectural.id',
                'Prefectural.region_id',
                'Prefectural.name',
                'Prefectural.created',
                'Prefectural.modified',
            ],
            'conditions' => [
                'City.id >' => 1101,
            ],
            'joins' => [
                [
                    'type' => 'INNER',
                    'alias' => 'Prefectural',
                    'table' => 'prefecturals',
                    'conditions' => [
                        "City.prefectural_id = Prefectural.id",
                    ]
                ],
            ],
            'conditions' => [
                'City.id >' => 1101,
            ],
            'order' => ['City.id DESC']
        ]);
        $this->assertTrue($result2 == $result3);
        $result2 = $this->cake2Model->find('all', [
            'fields' => [
                'City.prefectural_id',
                'COUNT(City.id)',
            ],
            'conditions' => [
                'City.id >' => 1101,
            ],
            'group' => ['City.prefectural_id'],
            'order' => ['City.prefectural_id'],
        ]);
        $result3 = $this->cake3Table->find('all', [
            'fields' => [
                'City.prefectural_id',
                'COUNT(City.id)',
            ],
            'conditions' => [
                'City.id >' => 1101,
            ],
            'group' => ['City.prefectural_id'],
            'order' => ['City.prefectural_id'],
        ]);
        $this->assertTrue($result2 == $result3);
    }

    public function testFindCount()
    {
        $result2 = $this->cake2Model->find('count');
        $result3 = $this->cake3Table->find('count');
        $this->assertTrue($result2 == $result3);
        $result2 = $this->cake2Model->find('count', [
            'conditions' => [
                'id >' => 1102,
            ],
            'joins' => [
                [
                    'type' => 'INNER',
                    'alias' => 'Prefectural',
                    'table' => 'prefecturals',
                    'conditions' => [
                        "City.prefectural_id = Prefectural.id",
                    ]
                ],
            ],
            'conditions' => [
                'City.id >' => 1101,
            ],
        ]);
        $result3 = $this->cake3Table->find('count', [
            'conditions' => [
                'id >' => 1102,
            ],
            'joins' => [
                [
                    'type' => 'INNER',
                    'alias' => 'Prefectural',
                    'table' => 'prefecturals',
                    'conditions' => [
                        "City.prefectural_id = Prefectural.id",
                    ]
                ],
            ],
            'conditions' => [
                'City.id >' => 1101,
            ],
        ]);
        $this->assertTrue($result2 == $result3);
    }

    public function testFindFirst()
    {
        $result2 = $this->cake2Model->find();
        $result3 = $this->cake3Table->find();
        $this->assertTrue($result2 == $result3);
        $result2 = $this->cake2Model->find('first');
        $result3 = $this->cake3Table->find('first');
        $this->assertTrue($result2 == $result3);
        $result2 = $this->cake2Model->find('first', [
            'fields' => [
                'City.id',
                'City.prefectural_id',
                'City.name',
                'City.created',
                'City.modified',
                'Prefectural.id',
                'Prefectural.region_id',
                'Prefectural.name',
                'Prefectural.created',
                'Prefectural.modified',
            ],
            'joins' => [
                [
                    'type' => 'INNER',
                    'alias' => 'Prefectural',
                    'table' => 'prefecturals',
                    'conditions' => [
                        "City.prefectural_id = Prefectural.id",
                    ]
                ],
            ],
            'conditions' => [
                'City.id >' => 1101,
            ],
            'order' => ['City.id DESC']
        ]);
        $result3 = $this->cake3Table->find('first', [
            'fields' => [
                'City.id',
                'City.prefectural_id',
                'City.name',
                'City.created',
                'City.modified',
                'Prefectural.id',
                'Prefectural.region_id',
                'Prefectural.name',
                'Prefectural.created',
                'Prefectural.modified',
            ],
            'conditions' => [
                'City.id >' => 1101,
            ],
            'joins' => [
                [
                    'type' => 'INNER',
                    'alias' => 'Prefectural',
                    'table' => 'prefecturals',
                    'conditions' => [
                        "City.prefectural_id = Prefectural.id",
                    ]
                ],
            ],
            'conditions' => [
                'City.id >' => 1101,
            ],
            'order' => ['City.id DESC']
        ]);
        $this->assertTrue($result2 == $result3);
    }

    public function testFindList()
    {
        $result2 = $this->cake2Model->find('list');
        $result3 = $this->cake3Table->find('list');
        $this->assertTrue($result2 == $result3);
        $result2 = $this->cake2Model->find('list', [
            'fields' => ['id', 'name'],
            'conditions' => [
                'id >' => 1101,
            ],
            'order' => ['id DESC']
        ]);
        $result3 = $this->cake3Table->find('list', [
            'fields' => ['id', 'name'],
            'conditions' => [
                'id >' => 1101,
            ],
            'order' => ['id DESC']
        ]);
        $this->assertTrue($result2 == $result3);
        $result2 = $this->cake2Model->find('list', [
            'fields' => ['id'],
            'conditions' => [
                'id >' => 1101,
            ],
            'order' => ['id DESC']
        ]);
        $result3 = $this->cake3Table->find('list', [
            'fields' => ['id'],
            'conditions' => [
                'id >' => 1101,
            ],
            'order' => ['id DESC']
        ]);
        $this->assertTrue($result2 == $result3);
        $result2 = $this->cake2Model->find('list', [
            'fields' => ['created'],
            'conditions' => [
                'id >' => 1101,
            ],
            'order' => ['id DESC']
        ]);
        $result3 = $this->cake3Table->find('list', [
            'fields' => ['created'],
            'conditions' => [
                'id >' => 1101,
            ],
            'order' => ['id DESC']
        ]);
        $this->assertTrue($result2 == $result3);
    }
}
```

# CakePHP2からCakePHP3のカスタム関数を呼び出す

上記方針に挫折したため、以下のアプローチをとることにしました。

- CakePHP3のModelを先に新規生成し、CakePHP2のカスタム関数を移植
- CakePHP2のControllerをCakePHP3に切り替え
  - このタイミングでValidationも移植

※この内容の詳細はCakeFest2019の以下のスライドで紹介しています。

https://speakerdeck.com/ykanazawa/consider-a-smooth-upgrade-to-cakephp-3?slide=36
<script async class="speakerdeck-embed" data-slide="36" data-id="636135f2c31947b791552abf77d6cae5" data-ratio="1.33333333333333" src="//speakerdeck.com/assets/embed.js"></script>

# 最後に

この挫折と方針変更についてはエンジニアブログにも書いております。
https://engineer.blog.lancers.jp/2019/10/cakephp2to3_model/

新しいアプローチで成功するかどうかはこれからの努力にかかっていますが、長い道のりになることは間違いないと思います。