CakeDCのsearch pluginの記事が少ないので1個置いときますね。CakePHP Advent Calendar 2010 8日目

CakePHP Advent Calendar 2010 8日目

http://cakephp.jp/modules/newbb/viewtopic.php?viewmode=flat&topic_id=2510&forum=16

CakePHP Advent Calendar 2010の順番が回ってきました。8日目のkanonjiです。

最近立て込んでいたので、実はみんなの記事を今日読みました。どれも参考になる記事で、結構CakePHPを分かってきたかなと思っていたんですが、まだまだだって事が分かりました。

前日のMASA-Pさんの記事「【CakePHP】CakePHP Advent Calendar2010(day 7) : Tips for Routes | ECWorks Blog」では、ちょうど最近はまっていたRoutesのtipsを教えてもらったので、早速後でroutes.phpを書き直そうと思います。
とあるページの特殊な設定をしたら、別のページが影響を受けたりと、まるで黒魔術*1の様だと頭を抱えてましたが、どうやら使い方を分かってなかったようですね。

CakeDC search plugin

本題ですが、CakePHPで複雑な検索機能を実現する GitHub - CakeDC/search: Search Plugin for CakePHP を使ったので、その使い方を紹介します。
使い始めた時は情報が少なくて、Readme.md を頼りにやってみたんですが、実は正しく使えてるかちょっと自信がありません。試行錯誤してたら動いたっぽい、そんな中途半端な情報ですが、発表しておけば次に繋がるかもという事で。

サンプルとして、やっつけですがこんな構成でデータベースを作りました。

User hasOne Profile
User hasMany Entries
User HABTM Groupp
Entry HABTM TAG

図に描けばよかったんですがアソシエーションはこんな感じです。ユーザーはグループに所属していて、プロフィールを別に持っています。ユーザーが記事を投稿して、その記事にはタグが付けられている。そんな簡易ブログの様な構成です。

なお、Profileを分けておきながら、上手く検索対象に出来なかったので、今回は使いません。
groupsというテーブルだと、SQL Syntax errorが出てしまったので、grouppsなんて変なテーブル名*2になってます。

これを元に、タグの有無や投稿ユーザーやグループなどを条件として、記事を検索する機能を作ります。CakePHPcssそのままなので、ちょっと画像がでかいですが、こんな画面です。

準備

$ pwd
/path/to/app/plugins
$ git clone https://github.com/CakeDC/search.git

Search pluginはコントローラーとモデルに、それぞれ専用のプロパティを書くことで、検索の動きを決めていけます。記事を検索するのでEntryモデルと、SearchsControllerを使いました*3

<?php echo $this->Form->create();?>
	<fieldset>
 		<legend><?php __('Seach Entry'); ?></legend>
	<?php
		echo $form->input('label',array('label'=>'Entry.label'));
		echo $form->input('email',array('label'=>'User.email'));
		echo $form->input('pageview',array('multiple'=>'checkbox','options'=>$pageviews,'label'=>'Entry.pageview'));
		echo $form->input('groupp_id',array('multiple'=>'checkbox','options'=>$groups,'label'=>'Groupp HABTM User'));
		echo $form->input('tag_id',array('multiple'=>'checkbox','options'=>$tags,'label'=>'Tag HABTM Entry'));
		echo $form->input('word');
	?>
	</fieldset>
<?php echo $this->Form->end(__('Search', true));?>

APP/views/search/index.ctp

まずViewに検索フォームを作ります。通常ならモデル名やフィールド名をそのまま書きますが、Seach pluginが間に入るので、モデル名は要りません。ちなみにmethodはPOSTです。

<?php
class SearchsController extends AppController {
    public $name = 'Searchs';
    public $uses = 'Entry';
    public $components = array('Search.Prg');
    public $presetVars = array(
        array('field' => 'label', 'type' => 'value'),
        array('field' => 'email', 'type' => 'value'),
        array('field' => 'pageview', 'type' => 'checkbox'),
        array('field' => 'groupp_id', 'type' => 'checkbox'),
        array('field' => 'tag_id', 'type' => 'checkbox'),
        array('field' => 'word', 'type' => 'value'),
    );

APP/controllers/searchs_controller.php

コントローラーの$presetVarsプロパティに、上記の様にViewに書いたフォームのname属性に対応する形で書いていきます。チェックボックスの様に、複数選択が出来る項目は 'type' => 'checkbox'。テキストボックスやラジオボタンの様に、単一データの場合は 'type' => 'value' にします。ほかに 'type' => 'lookup' というのや 'type' のほかに'model'や'formField'や'modelField'がありますが、使い方を知らないので触れません。

<?php
class Entry extends AppModel {
    var $name = 'Entry';
    public $actsAs = array('Search.Searchable');
    public $filterArgs = array(
        array('name' => 'label', 'type' => 'value'),
        array('name' => 'email', 'type' => 'value', 'field' => 'User.email'),
        array('name' => 'pageview', 'type' => 'query', 'method' => 'pageviewBetween'),
        array('name' => 'groupp_id', 'type' => 'subquery', 'field' => 'User.id', 'method' => 'searchByGroupps'),
        array('name' => 'tag_id', 'type' => 'subquery', 'field' => 'Entry.id', 'method' => 'searchByTags'),
        array('name' => 'word', 'type' => 'query', 'method' => 'findWithLike'),
    );

APP/models/entries.php

次はモデルに$filterArgsプロパティを書いていきます。$presetVarsの'field'に対応する形で、どう検索するかを設定します。この設定が頑張りどころなので、以下に検索方法ごとに分けて紹介します。

色々な検索の方法

自分が作った事がある方法だけですが、上記の設定に対応した形で紹介していきます。最初にも書きましたが、Search pluginの作者の意図した使い方かどうかわからないのと、もっと他の形の検索も実装出来るという事にご注意ください。

単純な完全一致

入力した値が、指定のフィールドにあるかどうかを調べるのは簡単です。

<?php
array('name' => 'label', 'type' => 'value'),

今回はEntryモデルを使っているので、entriesテーブルのlabelフィールドに値が完全一致であるかどうかという設定です。
SQL的には WHERE `Entry`.`label` = ? こんな感じです。

<?php
array('name' => 'email', 'type' => 'value', 'field' => 'User.email'),

JOINされているアソシエーションなテーブルに対しては、'field'にモデル名付きで設定します。

BETWEEN文的な検索
<?php
array('name' => 'pageview', 'type' => 'query', 'method' => 'pageviewBetween'),

エントリーにページビューが記録されているという形で、BETWEEN文で大体の幅に絞って検索します。本来は'type' => 'expression'というのがBETWEEN用にあるんですが、'expression'ではチェックボックスを選んで幅を決められなかったので、'type' => 'query'を使いました。'query'は指定できるtypeの中で一番自由度が高いものの様です。

<?php
    public function pageviewBetween($data){
        $input = $data['pageview'];
        $inputs = explode('|', $input);
        foreach($inputs as $input){
            switch ($input) {
                case '1':
                    $input = array(0, 9);
                    break;
                case '2':
                    $input = array(10, 49);
                    break;
                case '3':
                    $input = array(50, 99);
                    break;
                case '4':
                    $input = array(100, PHP_INT_MAX);
                    break;
                default:
                    $input = null;
            }
            if($input)
                $coditions['or'][] = array("{$this->alias}.pageview BETWEEN ? AND ?" => $input);
        }
        return $coditions;
    }

APP/models/entries.php

'type' => 'query' を使う場合は'method' => 'pageviewBetween'の様に処理をするメソッドを設定します。こんなpageviewBetween()メソッドをEntryモデルに作りました。'type' => 'query'では、最終的にModel->find()などで使う$options['conditions']をreturnする作りにします。チェックボックスからの値によって、BETWEENなconditionsを組み替えてreturnすれば出来上がりです。

HABTMを検索(OR検索)
<?php
array('name' => 'groupp_id', 'type' => 'subquery', 'field' => 'User.id', 'method' => 'searchByGroupps'),
array('name' => 'tag_id', 'type' => 'subquery', 'field' => 'Entry.id', 'method' => 'searchByTags'),

タグやグループの様なHABTAMな関係を検索します。今回はグループとタグの2つがあります。それぞれ、指定したグループに所属するユーザーが投稿したエントリーと、指定したタグが付いてるエントリーを検索します。

HABTAMの検索には'type' => 'subquery'を使います。これはField IN(subquery)の様なIN演算子を使うSQLを生成できます。サブクエリー自体は、'method'で指定したメソッドで自分で組み立てる事になります。

HABTMの中間テーブルがAppModelオブジェクトになってしまう問題の対応 - cakephperの日記(CakePHP, Laravel, PHP)

あと、HABTMの中間モデルを使うため、各モデルの$hasAndBelongsToManyのとこに、'with'=>'モデル名'*4を付ける必要があります。加えて、付けるからには中間モデルもBakeで作るとかしておく必要があります。これについては上記が詳しいですが、自分の環境では'with'=>''だと動作しませんでした。

<?php
    public function searchByGroupps($data = array()){
        $this->User->GrouppsUser->Behaviors->attach('Containable', array('autoFields' => false));
        $this->User->GrouppsUser->Behaviors->attach('Search.Searchable');
        $query = $this->User->GrouppsUser->getQuery('all', array(
            'conditions' => array('groupp_id'  => explode('|', $data['groupp_id'])),
            'fields' => array('user_id'),
            'contain' => $this->User->Groupp->alias,
        ));
        return $query;
    }

こんな感じでサブクエリーを作ります。タグのほうもモデル名とキー名以外は同じなので省略です。
実はこれはReadmeにあったメソッドのほぼコピペです。

public function findByTags($data = array()) {
$this->Tagged->Behaviors->attach('Containable', array('autoFields' => false));
$this->Tagged->Behaviors->attach('Search.Searchable');
$query = $this->Tagged->getQuery('all', array(
'conditions' => array('Tag.name' => $data['tags']),
'fields' => array('foreign_key'),
'contain' => array('Tag')
));
return $query;
}

https://github.com/CakeDC/search/blob/master/readme.md

ReadmeではContainableをアタッチしてましたが、後述するコントローラーのアクション内でContainableを使ったからか、なくても動いたので削っています。
CakePHPのsearch pluginのサンプルコードと記事をちょっと修正。Containableは必要でした - kanonjiの日記のように修正しました。上記のコードも修正しています。
また、'conditions' => array('Tag.name' => $data['tags']), となってますが、これはチェックボックスではなく、タグを入力しての完全一致になってます。チェックボックスでのOR検索にしたかったので、'conditions' => array('groupp_id' => explode('|', $data['groupp_id'])), の様にしました。

LIKE演算子をふんだんに使ったフリーワード検索
<?php
array('name' => 'word', 'type' => 'query', 'method' => 'findWithLike'),

完全一致は簡単ですが、部分一致で検索するにはやっぱり'type' => 'query'を使います。LIKE検索なのであまり多用・過信できませんが、やっぱり部分一致はしたいです。

<?php
    public function findWithLike($data = array()){
        $conditions['or'][] = array("{$this->alias}.label LIKE" => "%{$data['word']}%");
        $conditions['or'][] = array("{$this->alias}.body LIKE" => "%{$data['word']}%");
        $conditions['or'][] = array("{$this->User->alias}.username LIKE" => "%{$data['word']}%");
        $conditions['or'][] = array("{$this->User->alias}.email LIKE" => "%{$data['word']}%");
        $conditions['or'][] = array("{$this->User->alias}.label LIKE" => "%{$data['word']}%");
        return $conditions;
    }

'type' => 'query'はconditionsを組み立ててreturnすれば良いので、見ての通りです。

コントローラーのアクションを作る

ここまでやったら、あとはコントローラーにアクションを作るだけです。

<?php
    public function index(){
        if($this->data || $this->passedArgs){
            $this->Entry->Behaviors->attach('Containable');
            $this->paginate['contain'] = array(
                'User' => array(
                    'Groupp',
                    'Profile',
                ),
                'Tag',
            );
            $this->Prg->commonProcess();
            $this->paginate['conditions'] = $this->Entry->parseCriteria($this->passedArgs);
            $this->paginate['conditions']['Entry.is_running'] = 'true';
            $this->set('entries', $d = $this->paginate());//debug($d);
        }
        $tags = $this->Entry->Tag->find('list',array('fields'=>'label'));
        $groups = $this->Entry->User->Groupp->find('list',array('fields'=>'label'));
        Configure::load('selection');
        $locations = Configure::read('selection.location');
        $locations = array_combine($locations, $locations);
        $pageviews = Configure::read('selection.pageview');
        $this->set(compact('tags','groups','locations','pageviews'));
    }
<?php
if($this->data || $this->passedArgs){

Search pluginはフォームからPOSTでデータを受け取り、Named parameters*5に詰めなおしてから、1度リダイレクトします。なので、フォームから入力があるかどうかは$this->passedArgsも見る必要があります。
ちなみに、Named parametersへの詰めなおしとリダイレクトは $this->Prg->commonProcess(); がやってます。

<?php
$this->paginate['conditions'] = $this->Entry->parseCriteria($this->passedArgs);

Named parametersが着たらparseCriteria()に渡すと、これまでにEntryモデルに書いた設定によってconditionsが生成されます。これを$this->paginate()にでもModel->find()にでも、渡してあげれば検索が出来ます。あとは、検索結果画面のViewを作れば完成です。

http://d.hatena.ne.jp/aroundthedistance/20090728/1248784179

ちょっと確認はしてませんがpaginateCoung()をオーラーライドする必要があるかもしれません。自分がSearch pluginを使ったアプリでは、上記で解説されてる方法でオーバーライド済みでした。

<?php
$this->paginate['conditions'] = $this->Entry->parseCriteria($this->passedArgs);
$this->paginate['conditions']['Entry.is_running'] = 'true';

parseCriteria()の後は普通の conditions なので、このように別の条件を追加したり出来ます。これは公開/非公開フラグがtrueである条件を追加しました。

一応サンプルを置いておきます。

GitHub - kanonji/CakePHP-Search-plugin-sample: A sample app for CakeDC's search plugin. But I'm not sure these are right way to use. Blog is written by Japanese.

APP/config/database.php
無いので作成してください。
APP/config/schema/schema.php
あります。
APP/tmp/
無いので作成してください
APP/controllers/inits_controller.php
各テーブルに数レコード、初期データを入れる処理を書いてあります。Viewは作ってませんしエラーハンドリングしてません。

環境

Mac Mac OS X 10.5.8(Leopard
MAMP 1.7.2
CakePHP 1.3.6
php 5.2.6
CakeDC Search plugin updating readme · CakeDC/search@668eb68 · GitHub

この記事と、上記サンプルはこんな環境で確認してます。

次のCakePHP Advent Calendar 2010

次の担当はhttp://cake.eizoku.com/advent/によると id:ixcy さんです。が、リンク先を知らないので、分かり次第リンクします><
どんなCakePHPの記事が出てくるか、CakePHP Advent Calendar 2010では色んな発見があるので、楽しみです。


リンク先が分かったので貼りました。ついでに見直してタイポや変な言い回しを少し直しました。

*1:mod_rewrite

*2:モデルやコントローラーも

*3:Entryモデルを使うのでEntriesControllerでも良かったんですが

*4:このサンプルだと'with' => 'EntriesTag' と 'with' => 'GrouppsUser'

*5:名前付きパラメーター