CakePHP
cakephp2
CakePHPDay 3

[CakePHP2] カスタムファインダーの使い方と利便性

カスタムファインダーとは

カスタムファインダー とは Model が提供するファインダーの中で find('all') でも find('first') でも find('list') でも find('count') でも find('threaded') でも find('neighbors') でもない独自のファインダーのことです。

たとえば、データベース中から有効なユーザーのみを取得したいとします。こうした場合にカスタムファインダーを活用することができます。頻繁に使用する検索条件であればコードの重複を削減できますし、そうではなくて、あるアクション固有の処理だったとしても CakePHP ではコントローラーをスリムにしてモデルを太らせるのが鉄則 1 です。

カスタムファインダーを作成する

では、さっそくカスタムファインダーを作ってみましょう。有効なユーザーのみを取得する find('available') の実装は次のようになります。

app/Model/User.php
class User extends AppModel {

    public $findMethods = [
        'available' => true, // find('available') できるようする
    ];

    /**
     * find('available') の実装
     */
    protected function _findAvailable($state, $query, $results = []) {
        if ($state === 'before') {
            $query['conditions']["{$this->alias}.is_available"] = true;
            return $query;
        }

        return $results;
    }

}

これで find('all') の代わりに find('available') できるようになりました。

コードの説明をすると find('available') の実装である _findAvailable() は、一度の find('available') の呼び出しで内部的には二度呼び出されます。

一度目は $state === 'before' になる呼び出しです。この呼び出しでは第二引数 $query として、 find() を呼び出した際のクエリー配列 ('conditions''order' など) が渡されます 2。これを加工して返すことで、検索条件の追加や変更を行うことができます。上の例では is_available に真を指定しています 3

二度目は $state === 'after' になる呼び出しで、この呼び出しでは第三引数 $results としてクエリー実行結果の配列が渡されます。これを加工して返すことで、フィールドの追加や変更を行うことができます。あるいは find('threaded') などのように、結果の配列構造全体を変えてしまうことだって可能です。上の例では、特にすることもないので何もせずにそのまま返しています。

カスタムファインダーは、こうした二度の呼び出しを利用して、任意の検索条件による検索結果を find() から返すことができる仕組みになっています。

ちなみに、結果を加工する例は次のようになります。

app/Model/User.php
class User extends AppModel {

    public $findMethods = [
        'available' => true,
        'withFullName' => true, // find('withFullName') もできるようする
    ];

    protected function _findAvailable($state, $query, $results = []) {
        // 省略
    }

    /**
     * find('withFullName') の実装
     */
    protected function _findWithFullName($state, $query, $results = []) {
        if ($state === 'before') {
            return $query;
        }

        // 取得したレコードの family_name と given_name を空白で結合
        foreach ($results as &$result) {
            $user =& $result[$this->alias];
            $user['full_name'] = $user['family_name'] . ' ' . $user['given_name'];
        }

        return $results;
    }

}

これは、仮に users テーブルが氏名を格納するためのフィールド family_namegiven_name を持っている場合に、それらを結合した full_name というテーブルには存在しないフィールドを検索結果に含める find('withFullName') の実装例です。

ところで、上の例の $result の前にある & を見て、眼鏡にゴミでも付いてるんじゃないかと思った人は、眼鏡拭きを取り出す前に PHP マニュアルの リファレンスの説明 を読んでみてください。一言で説明すると、リファレンス (参照) というのは長い変数に短い別名を与えることができる機能です。参照を使わずに書くと下のように少し冗長になってしまいます。

foreach ($results as $index => $result) {
    $user = $result[$this->alias];
    $results[$index][$this->alias]['full_name'] = $user['family_name'] . ' ' . $user['given_name'];
}

ただし、参照は使い方を間違えると思わぬ結果を招くことがあります。 PHP マニュアルの foreach のページにその警告が書かれていますので危険性をご存知でない方はそちらもご一読ください。

通常のメソッドとの比較

まだカスタムファインダーの利便性がよく分からないという方がいらっしゃるかもしれません。というのも、モデルの通常のメソッドでも同じことができそうだからです。たとえば User モデルに getAvailableUsers()getUsersWithFullName() を作るのとカスタムファインダーを作るのとでは、構文の他に何か違いはあるのでしょうか?

もちろん、違いはあります。カスタムファインダーはカウントやページ分けで使用することができるのです。

たとえば、有効なユーザーの総件数をカウントしたい場合、 find('count')'type' オプションにカスタムファインダー名を指定します。

app/Controller/UsersController.php
class UsersController extends AppController {

    public function example() {
        $count = $this->User->find('count', ['type' => 'available']);

        debug($count);
    }

}

こうすると find('count')find('available') を使用したカウントを行ってくれます 4

ページ分けでは PaginatorComponent の設定の 'findType' にカスタムファインダー名を渡します。

app/Controller/UsersController.php
class UsersController extends AppController {

    public $components = ['Paginator'];

    public function example() {
        $this->Paginator->settings['findType'] = 'available';
        $users = $this->Paginator->paginate();

        debug($users);
    }

}

これで有効なユーザーのみをページ分け表示することができます。もしも PaginatorComponentconditionscontain 、あるいは group などを渡している箇所があれば、それはカスタムファインダーの出番かもしれません。

また CakePHP 2.8 以降では カスタムマジックファインダー も使用可能です。

app/Controller/UsersController.php
class UsersController extends AppController {

    public function example($companyId = null) {
        $users = $this->User->findWithFullNameByCompanyId($companyId);

        debug($users);
    }

}

メソッド名の中で By 以下の部分は呼び出し時に任意のフィールドを指定可能ですので、検索に使うフィールドが異なるだけで後は同じ処理、というメソッドを複数作成しなくてすむようになります。

コールバックメソッドとの比較

もしかすると、 コールバックメソッド でも同じことができるのではないかと思われた方がいらっしゃるかもしれません。つまり beforeFind() でクエリーを加工して afterFind() で結果を加工すればカスタムファインダーなんて必要ないのではないか、という疑問です。これならカウントでもページ分けでも動作しそうです。

結論から言うと、カスタムファインダーはほとんどすべての場合においてコールバックメソッドに勝ります。以下のような理由が挙げられます。

  • カスタムファインダーには名前があります。一つのモデルは beforeFind()afterFind() をそれぞれ一つしか持つことができません。
  • コールバックメソッドは暗黙的です。 find('all') の結果に無効なユーザーが含まれないのは、 find('available') の結果がそうであるよりもずっと予想を裏切ります。
  • コールバックメソッドはそれぞれ独立しています。 afterFind() は対になる beforeFind() を知ることができません。よって、 afterFind() 中で find() を呼び出すと簡単に壊れてしまいます。
  • コールバックメソッドの afterFind() は、場合によっては二度呼ばれたり、異なる配列構造で結果が渡されたりすることがあります 5

これらの欠点を押してまで、あえてコールバックメソッドで実装する必要はないはずです。

CakePHP3 との比較

CakePHP3 ではカスタムファインダーの記法が次のように変わっています。

src/Model/Table/UsersTable.php
class UsersTable extends Table {

    // public $findMethods による設定は廃止

    /**
     * CakePHP3 における find('available') の実装
     */
    public function findAvailable(Query $query, array $options) {
        // このブロックが $state === 'before' に相当

        return $query->where([
                $this->aliasField('is_available >=') => true,
            ])
            ->formatResults(function($results) {
                // このブロックが $state === 'after' に相当

                return $results;
            });
        });
    }

}

CakePHP2 における $state === 'before' に相当するクエリー加工はメソッド中で行い、 $state === 'after' に相当する結果加工は必要に応じて formatResults() メソッドを呼び出して渡すクロージャー中で行います。 CakePHP2 に慣れている方が CakePHP3 を使用する場合、あるいはその逆の場合には、この関係性を覚えておくと理解が早いかもしれません。

また、小さな変更点として $findMethods でカスタムファインダーを有効にする必要がなくなりました。そもそも、何でそんなことをしなければならなかったんですかね。

それから、メソッド名の先頭の _ が取れ、修飾子が public になりました。ただ、こちらについては CakePHP2 の仕様の方がよかったと思います。入力補完機能を使用している場合に $table->f からの補完で findAll() が候補に現れることに意味はありませんし findOrCreate() というカスタムファインダーと名前が衝突するメソッドがコアの段階から存在してしまっています。

大きな変更点としては CakePHP3 ではカスタムファインダーを任意に組み合わせることができるようになりました。これについては次の節で詳述します。

カスタムファインダーを組み合わせる

さて、有効なユーザーを取得するカスタムファインダーと、ユーザーをフルネーム付きで取得するカスタムファインダーをすでに作りました。それでは、これらを利用して有効なユーザーのみをフルネーム付きで取得したい場合にはどのようにすればよいのでしょうか? つまり find('available')find('withFullName') を組み合わせたいのです。

CakePHP3 ではこうした機能を提供しており、次のコードがその例です。

src/Controller/UsersController.php
class UsersController extends AppController {

    public function example() {
        $users = $this->Users
            ->find('available')
            ->find('withFullName')
            ->all();
    }

}

残念ながら CakePHP2 は組み込み機能としては、こうしたカスタムファインダーを動的に組み合わせるような仕組みは提供しません 6

しかし、複数のカスタムファインダーを組み合わせる方法自体が存在しないわけではありません。動的には組み合わせることができないというだけであって、あらかじめ別のカスタムファインダー、ここでは _findAvailableWithFullName() を定義しておけばよいのです。

ただ、肝心のその方法が少なくとも公式 Cookbook には載っていませんので、もしかしたらあまり書き方が知られていないかもしれません。次のような書き方になります。

app/Model/User.php
class User extends AppModel {

    public $findMethods = [
        'available' => true,
        'withFullName' => true,
        'availableWithFullName' => true, // find('availableWithFullName') もできるようにする
    ];

    protected function _findAvailable($state, $query, $results = []) {
        // 省略
    }

    protected function _findWithFullName($state, $query, $results = []) {
        // 省略
    }

    /**
     * find('availableWithFullName') の実装
     */
    protected function _findAvailableWithFullName($state, $query, $results = []) {
        if ($state === 'before') {
            $ref =& $query;
        } else {
            $ref =& $results;
        }

        $ref = $this->_findAvailable($state, $query, $results);
        $ref = $this->_findWithFullName($state, $query, $results);
        return $ref;
    }

}

_findAvailable()_findWithFullName() を続けて呼び出していますが、実はこれだけでカスタムファインダーを組み合わせることができます。組み合わせるカスタムファインダーを増やしたい場合には、同じ要領で一行追加するだけです。 CakePHP3 ほど柔軟ではありませんが、そうたいした手間ではないはずです。

ちなみに、この例でも参照を使用しました。参照を使用せずに書き直したコードは次のようになります。

protected _findAvailableWithFullName($state, $query, $results = []) {
    if ($state === 'before') {
       $query = $this->_findAvailable($state, $query, $results);
       $query = $this->_findWithFullName($state, $query, $results);
       return $query;
    }

    $results = $this->_findAvailable($state, $query, $results);
    $results = $this->_findWithFullName($state, $query, $results);
    return $results;
}

やはり、参照を使わないとコードが冗長になりますね。ただし、場合によっては参照を使わない方が読みやすいこともあります。次の節ではその一例が登場します。

組み込みのファインダーと組み合わせる

今度はユーザーをフルネームのリスト形式で取得したいとします。 find('withFullName')find('list') の組み合わせです。

実は find('list') のような組み込みのファインダーも、仕組み上はカスタムファインダーとまったく同じで、たとえば find('list') の実装は Model クラスの _findList() で行われています。したがって、組み合わせることは可能ですが、組み込みのファインダーには複雑な処理を行うものが多く、クエリーを少し調整する必要があります。次のようになります。

app/Model/User.php
class User extends AppModel {

    public $findMethods = [
        'available' => true,
        'withFullName' => true,
        'availableWithFullName' => true, 
        'fullNameList' => true, // find('fullNameList') できるようにする
    ];

    protected function _findAvailable($state, $query, $results = []) {
        // 省略
    }

    protected function _findWithFullName($state, $query, $results = []) {
        // 省略
    }

    protected function _findAvailableWithFullName($state, $query, $results = []) {
        // 省略
    }

    /**
     * find('fullNameList') の実装
     */
    protected function _findFullNameList($state, $query, $results = []) {
        if ($state === 'before') {
            $query = $this->_findWithFullName($state, $query, $results);
            $query = $this->_findList($state, $query, $results);

            // find('withFullName') に必要なフィールドを取得
            $query['fields'] = array(
              "{$this->alias}.id",
              "{$this->alias}.family_name",
              "{$this->alias}.given_name",
            );

            // リストの値として full_name を指定
            $query['list']['valuePath'] = "{n}.{$this->alias}.full_name";

            return $query;
        }

        $results = $this->_findWithFullName($state, $query, $results);
        $results = $this->_findList($state, $query, $results);
        return $results;
    }

}

コードの説明をすると、内部的に _findList()$state === 'before' の呼び出しで
$query['fields'] にリスト構築用の最小限のフィールドを設定します。既定は Model::$primaryKeyModel::$displayField で、通常は 'id''name' などになるでしょう。

しかし、 find('withFullName')'family_name''given_name' を必要としますので、これでは動作しなくなってしまいます。そこで、上の例では _findList() 呼び出し後に、これらのフィールドを取得するように指定し直しています。

また _findList()$query['list'] にリスト構築用の設定を格納します。これは Hash::combine() に渡す設定です。今回はリストの値については 'full_name' にしたいので、 $query['list']['valuePath']'{n}.User.full_name' になるようにしています 7

ビヘイビアーでファインダーを作成する

カスタムファインダーは ビヘイビアー でも作成することができます。たとえば、氏名の結合処理を User 以外のモデルでも使いたくなるかもしれません。

こうした場合、ビヘイビアーを使用するのは妥当な選択ですが、 CakePHP2 ではカスタムファインダーは _ から始めなければならず、一方でビヘイビアーは _ 始まりのメソッドはモデルにミックスインとして提供しないという二つの相容れない仕様のために、作成は多少面倒です。具体的にはビヘイビアー中で $mapMethods を宣言してカスタムファインダーをマップする必要があります。

app/Model/User.php
class User extends AppModel {

    public $actsAs = ['Person']; // PersonBehavior を使用します 

}
app/Model/Behavior/PersonBehavior.php
class PersonBehavior extends ModelBehavior {

    public $mapMethods = [
        '/^_findWithFullName$/' => '_callFinder', // _findWithFullName() を _callFinder() にマップ
    ];

    public function setup(Model $model, $settings = []) {
        $model->findMethods['withFullName'] = true; // find('withFullName') できるようにする
    }

    /**
     * カスタムファインダーを呼ぶためのメソッド
     */
    public function _callFinder(Model $model, $method, $state, $query, $results = []) {
      return $this->$method($model, $state, $query, $results);
    }

    /**
     * find('withFullName') の実装
     */
    protected function _findWithFullName(Model $model, $state, $query, $results = []) {
        if ($state === 'before') {
            return $query;
        }

        foreach ($results as &$result) {
            $user =& $result[$model->alias];
            $user['full_name'] = $user['family_name'] . ' ' . $user['given_name'];
        }

        return $results;
    }

}

カスタムファインダーの定義が少し変わっているので注意してください。ビヘイビアーのメソッドでは通常は第一引数としてモデルのインスタンスを受け取ります。

ちなみに、本来はメソッドのマップを行うと上記に加えて第二引数として実際に呼び出されたメソッド名が渡ってきます。さらに、マップするメソッドは public でなくてはなりませんが、上の例では _callFinder() という緩衝用のメソッドをマップして、このメソッド経由で実際のカスタムファインダーを呼んでいます。こうすれば、少なくともカスタムファインダー自体は protectedprivate でよくなり、また引数から無駄なメソッド名を省くことができます。

それから、メソッド中の処理においても $this->alias としていた個所が $model->alias に変わっていますので、こちらにも注意してください。ビヘイビアーでは $this は当然、モデルではなくビヘイビアー自身を指します。

やはり、少し面倒ですね。これなら PHP 5.4 で登場した トレイト を使った方がいいと思われる方もいらっしゃるかもしれません。そうかもしれませんし、そうではないかもしれません。というのも、ビヘイビアーにはトレイトにはない特徴の一つとして動的に取り付けが行える点があるからです。つまり、ビヘイビアーであれば、ユーザーの設定に応じて WesternPersonBehavior を取り付けて氏名の結合順を反対にする、といったことができるわけです。

ビヘイビアーでファインダーを組み合わせる

ファインダーはモデルでは protected として宣言されます。では、ビヘイビアーのカスタムファインダー中で、モデルのファインダーを組み合わせたい場合、どのようにすればいいのでしょうか?

もちろん AppModel ですべてのファインダーを public として宣言し直してもよいのですが、別の方法もあります。実は CakePHP では、ほとんどの protected メソッドは外部から呼び出し可能です。というのも CakePHP のコアライブラリーの一般的な基底クラスである Object (2.8 以降は CakeObject) クラスには dispatchMethod() という protected メソッドを呼び出すための手段が用意されているからです。

たとえば、ユーザーをフルネームのリストで取得する find('fullNameList') を作成したい場合は、次のようにします。

app/Model/Behavior/PersonBehavior.php
class PersonBehavior extends ModelBehavior {

    public $mapMethods = [
        '/^_findWithFullName$/' => '_callFinder',
        '/^_findFullNameList$/' => '_callFinder', // _fullNameList() も _callFinder() にマップ
    ];

    public function setup(Model $model, $settings = []) {
        $model->findMethods['withFullName'] = true;
        $model->findMethods['fullNameList'] = true;  // find('fullNameList') もできるようにする
    }

    public function _callFinder(Model $model, $method, $state, $query, $results = []) {
        // 省略
    }

    protected function _findWithFullName(Model $model, $state, $query, $results = []) {
        // 省略
    }

    /**
     * find('fullNameList') の実装
     */
    protected function _findFullNameList(Model $model, $state, $query, $results = []) {
        if ($state === 'before') {
            $query = $model->dispatchMethod('_findWithFullName', [$state, $query, $results]);
            $query = $model->dispatchMethod('_findList', [$state, $query, $results]);

            // find('withFullName') に必要なフィールドを追加
            $query['fields'] = array(
              "{$model->alias}.id",
              "{$model->alias}.family_name",
              "{$model->alias}.given_name",
            );

            // リストの値として full_name を指定
            $query['list']['valuePath'] = "{n}.{$model->alias}.full_name";

            return $query;
        }

        $results = $model->dispatchMethod('_findWithFullName', [$state, $query, $results]);
        $results = $model->dispatchMethod('_findList', [$state, $query, $results]);
        return $results;
    }

}

なお _findWithFullName() はこのビヘイビアーで定義されているメソッドなので、モデルを経由せずに $this から直接呼び出すこともできますが、この例では記述の統一を優先しています。また、この書き方にしておくと、モデルで _findWithFullName() を定義して処理をオーバーライドするチャンスを与えることができます。

カスタムファインダー関連のよくあるエラー

最後にカスタムファインダー作成時に発生しがちなエラーを紹介しておきます。

Notice (8): Undefined index: カスタムファインダー名 [CORE\Cake\Model\Model.php, line 行番号]
この Notice は $findMethods の設定が誤っている場合に発生します。設定を忘れていないか、あるいはタイプミスをしていないか確認してみてください。

Database Error + SQL Query: カスタムファインダーメソッド名
_findAvailable などのカスタムファインダーのメソッド名を、そのままデータベースに投げてしまう例外が発生した場合、カスタムファインダーの定義に誤りがないか確認してみてください。ビヘイビアーのカスタムファインダーを作っていた場合は、モデルのカスタムファインダーを dispatchMethod() せずに直接呼んでいないかも確認してみてください。

終わりに

カスタムファインダーは、コントローラーをスリムにしてモデルを太らせるための効果的な手段です。 CakePHP3 で強化された機能の一つではありますが、 CakePHP2 においても十分に役立ってくれるはずです。もしもコントローラー中で find() の条件を構築するようなコードを書いていたら、ぜひカスタムファインダーを作ってみてください。


  1. CakePHP 2.x Cookbook 「コントローラ」 の最初の段落にそう書いてあります。 

  2. 正確には find() に渡した配列そのものではなく 'conditions' などの主要なキーが存在しなければ追加され、 'page''limit''offset' に解決されるなど、多少の加工を受けた配列が渡されます。 

  3. ちなみに 'User.is_available' とせずに "{$this->alias}.is_available" としているのは User モデルに Author といった別名を与えることがあるかもしれないからです。 

  4. なお、この場合には $state === 'after' の呼び出しは行われません。結果を加工して数を減らしていた場合は、期待通りのカウント数にはなりません。 

  5. CakePHP 2.6 未満のバージョンに限ります。ちなみに直したのは私です。 

  6. 拙作の StackableFinder プラグインを使うと CakePHP3 のような記法が利用可能です。 

  7. User の部分は変わるかもしれないので、今回も $this->alias を使用しました。