CakePHP2 でシャカリキに効率よく contain する

Cookbook の [New ORM Upgrade Guide](http://book.cakephp.org/3.0/en/appendices/orm-migration.html#new-orm-upgrade-guide) には、以下のように書かれています。

> Containable - Should be part of the ORM, not a crazy hacky behavior.

（Containable - ORM の一部であるべきである、クレイジーでハッキーなビヘイビアではなく）

`ContainableBehavior` といえば、効率的なアソシエーションのデータ取得のために `contain` を提供する標準のビヘイビアです。
その `ContainableBehavior` が、いったいどれほど狂っていたというのでしょうか？

この記事では、`ContainableBehavior` の実装とその問題点を検証した上で、CakePHP2 でシャカリキに効率よく `contain` するためのプラグイン [EagerLoader](https://github.com/chinpei215/cakephp-eager-loader) をご紹介します。

## ContainableBehavior の正体

CakePHP2 の `ContainableBehavior` は、一時的な `recursive` の変更と、アソシエーションの切り離しによって実装されています。

ここに `User` 、`Group` 、`Domain` および `Post` という 4 つのモデルがあるとします。`User` は `Group` に、`Group` は `Domain` にそれぞれ従属し、また、`User` は `Post` を所有します。
なお、この他にもモデル名こそ登場しませんが、さまざまなアソシエーションが存在すると考えてください。

さて、`User` と関連する `Group` および `Domain` のデータが必要になったとします。
`User` から `Domain` までを一息に取得するには `recursive` レベルを `2` 以上にすればよいのですが、それでは、必要のない `Post` やその先に存在するであろう、何だかよくわからないアソシエーションのデータまで一緒くたに返されてしまうでしょう。

```php
$User->find('first', [
	'recursive' => 2,
]);
```

![contain1.png](https://qiita-image-store.s3.amazonaws.com/0/107118/54b1b169-4664-be41-5b69-63fd646b72a1.png)

上の図でいえば、オレンジ色で示したアソシエーションまでが返されてしまいます。

`ContainableBehavior` では、この問題を解決するために `Model.beforeFind` イベントを捉まえて `unbindModel()` を呼び出し、不要なアソシエーションを切り離してしまいます。

```php
$User->find('first', [
	'contain' => [
		'Group.Domain',
	],
]);
```

上の例では、`User` には `Group` との関係のみを残し、`Post` 等のアソシエーションを切り離します。同様に `Group` についても `Domain` との関係のみを残します。

![contain2.png](https://qiita-image-store.s3.amazonaws.com/0/107118/96ac302e-cf5a-7c61-724e-6ec86d6d1721.png)

切り離し後のアソシエーションはこのようになります。

不要なアソシエーションを切り離した都合、`User` から見える関係は `Group` とその先の `Domain` のみになっていますので、この状態で `recursive` レベルを `2` に上げて全てのデータを取得すれば、結果的に `contain` したことになるという寸法です。

なお、切り離されてしまったアソシエーションについては、`find()` がデータを返却する前に、内部的に `resetAssociations()` を呼び出すことで、ひそかに元に戻して誤魔化しています。

これが `ContainableBehavior` の正体です。
なかなか見事な実装で、とてもクレイジーだとは思えません。むしろ、発案者は賞賛に値するのではないでしょうか。

――しかし悲しいかな、クレイジーであろうとなかろうと、`ContainableBehavior` は確かにハッキ―ではあるのです。

## 単一インスタンスの落とし穴

実は `ContainableBehavior` によるハックでは処理できない `contain` パターンがあります。
それが以下のようなパターンです。

```php
$Comment->find('all', [
	'contain' => [
		'Post.User.Group',
		'User',
	]
]);

```

問題となるのは `User` と `Group` の関係です。
`Post` を投稿した `User` については `Group` を要求していますが、`Comment` を投稿した `User` については要求していません。
しかし、CakePHP2 のアソシエーションでは、各モデルのインスタンスは単一で、アプリケーション全体で共有します。したがって `Comment` を投稿した `User` についても `Group` との関係は維持されることになります。
この例の場合、 `ContainableBehavior` は `recursive` レベルを `3` に設定しますので、`recursive` レベル `2` でも事足りる `User.Group` はあえなく鹵獲され、予期せずしてレコードセットに含まれることになります。

## afterFind() という悪魔

`ContainableBehavior` を台無しにするための、より効果的な方法は `afterFind()` コールバック中に `find()` を呼び出すことです。`find()` は内部的に `resetAssociations()` を呼び出しますので、`ContainableBehavior` が切り離したアソシエーションをすべて元に戻してしまいます。
先の `User` から `Group.Domin` を取得する例であれば、たとえば `Domain` に従属する `Group` の数を `Domain::afterFind()` 内で求めるようなコードがこの問題を生じます。

```php
public function afterFind($results, $primary = false) {
	foreach ($results as &$result) {
		if (isset($result[$this->alias]['id'])) {
			$result[$this->alias]['group_count'] = $this->Group->find('count', [
				'conditions' => ['Group.domain_id' => $result[$this->alias]['id']],
				'callbacks' => false,
			]);
		}
	}
	return $results;
}
```
このコードでは `find('count')` が `resetAssociations()` を呼び出してしまうため、その時点で `Group` から `User` への所有関係が元に戻ってしまいます。`recursive` レベルは `2` ですので `Group` に属する全 `User` のデータを予期せずレコードセットに含めてしまうでしょう。
さながら、縁を切ったはずの不良グループから悪いメンバーたちが一斉に訪れたかのようです。

`ContainableBehavior` には、以上のような問題があります。
しかし、この程度のことであれば、それほど大きな問題とは言えないでしょう。
CakePHP2 の ORM において問題があるとすれば、それは `ContainableBehavior` ではなく `recursive` の実装――すなわち、`DboSource` なのです。

## N + 1 問題

ORM には N + 1 問題という避けては通れない問題が存在します。

任意の `Group` について、そこに所属する `User` を取得する処理を考えてみます。
単純な実装では、まず `Group` の取得のためにまず 1 回のクエリが発行されます。
そして、次なる `User` の取得は `group_id = ?` という条件式で行われることになるのですが、すると、このクエリは先に取得した `Group` の個数 N 回発行しなければなりません。
最初に発行した 1 回を足し合わせて、これを N + 1 問題と呼びます。

もっとも CakePHP2 の `DboSource` の実装はここまでは単純ではありません。
上記の例では、`DboSource` は 2 回のクエリしか発行しません。なぜなら `User` の取得は `group_id IN (...)` という効率的なクエリによって、一括で行われるためです。

しかし、`DboSource` の N + 1 問題対策は万全ではありません。
たとえば、任意の `Group` について、そこに所属する `User` だけでなく、その `Profile` まで取得したい場合に、`DboSource` は N + 1 問題を生じます。
優れた実装であれば、`Group` の取得のための 1 回はさて置き、次なる `User` と `Profile` の取得は、これらが `hasOne` の関係であれば、LEFT JOIN によって処理し、結果として 2 回のクエリで済ませるべきでしょう。

しかし、`DboSource` は `Profile` を結合せずに再帰処理へ回してしまいます。
さらに悪いことに、`DboSource` は `IN` による効率化を `hasMany` の関係にあるモデルでしか行わないため、次なる `Profile` の取得は完全に `User` 数に依存して、N 回のクエリを発行してしまいます。

これはパフォーマンス上の大きな問題となります。
CakePHP3 では新しい ORM の一部である `Cake\ORM\EagerLoader` クラスがこの問題を解決しています。
`contain` を実装しているのも主にこのクラスで、実際に上述したような効率的なクエリを発行する実装になっています。

CakePHP2 においても、どうにかして効率的なクエリで `contain` を行うことはできないものでしょうか――？

それが今回ご紹介する `EagerLoader` プラグインです。

インストールするだけで `contain` が発行するクエリを劇的に改善します。
ちなみに `afterFind()` 内での `find()` 呼び出しの問題なども解決しています。
`find()` の返値については `ContainableBehavior` と同等のレコードセットを返すように実装したつもりですので、通常の使用の範囲では問題は生じないと思いますが、以下に挙げる互換性のない仕様もあります。

## ContainableBehavior と互換性のない仕様

### contain() メソッド未実装
現時点では `contain()` メソッドについては実装していません。
代わりに `find()` の `contain` オプションをご利用ください。

### フィールド指定非サポート
`['contain' => 'Group.name']` のようなフィールド指定はサポートしていません。
代わりに `['contain' => ['Group' => ['fields' => 'name']]]` をご利用ください。 

### hasOne / belongsTo 結合失敗データの形式
常に空配列になります。
`ContainableBehavior` は、`recursive` レベル `0` で失敗した場合に、すべてのフィールドに `NULL` が格納されたレコードを含むことがあります。
なお、`EagerLoader` プラグインにおいても `afterFind()` コールバックに渡されるデータは、こうした `NULL` レコードになる場合があります。

### アソシエーションの並び順
各レコードの連想配列において、プライマリモデルを除いて、その他のアソシエーションの並び順は同じにはなりません。

こうした `ContainableBehavior` と互換性のない仕様に依存するコードを書いている場合にはご注意ください。

## インストール手順

### プラグインの取得
Composer からインストールすることができます。

```javascript
{
    "require": {
        "chinpei215/cakephp-eager-loader": "^0.1"
    }
}
```

もちろん、GitHub の [EagerLoader](https://github.com/chinpei215/cakephp-eager-loader) レポジトリから git clone する、あるいは zip ファイルをダウンロードすることもできます。

### プラグインの有効化
`app/Config/bootstrap.php` 内で `EagerLoader` プラグインを有効化してください。

```php
CakePlugin::load('EagerLoader');
```

### ビヘイビアの有効化
モデルで `EagerLoader.EagerLoader` ビヘイビアを有効化してください。

```php
public $actsAs = [
  'EagerLoader.EagerLoader',
  //'Containable', 
];
```

`Containable` は無効化するか、`EagerLoader` よりも優先度を下げてください。

あるいは、既存のプロジェクトに導入する場合には、互換性の問題の避けるために `$actsAs` に設定する代わりに遅いクエリの直前でのみ動的にビヘイビアを読み込む方法も有効です。

```php
// 動的な読み込み
$User->Behaviors->load('EagerLoader.EagerLoader');

$User->find('first', [
	'contain' => [
		'Group.Domain',
	],
]);
```

インストールはこれで完了です。

最後まで記事をお読みいただきありがとうございます。そして、プラグインをお使いになった皆様からのフィードバックをお待ちしております。