CakePHP2系でSearch Pluginを自由自在に

検索は、Web開発で超頻出の項目です。
そしてシステムをしらない人が、なぜか「開発超大変」と思っている項目でもあり、さらっとできると割とおいしいなぁと感じています。
今回はCakeDC/search、いわゆるSearchプラグインを使い、僕流のCakePHPで検索を自由に実装するポイントを書いてみます。

Searchプラグインを避けるという選択肢

唐突ですが、「CakePHP始めて2週間です」みたいな人がいたら、Searchプラグインを使わないという選択肢を検討してみてください。

検索って、「フォームから受け取った値を元にクエリの条件変えて表示する」という行為で、特に難しくもないです。知らないライブラリで詰まった際、問題を把握し解決するよりも圧倒的に。

初めてCakePHPで検索を実装した時のこと。3日間ほどSearchプラグインで詰まり、切り替えて自前で実装したら数時間でできてしまった。

プラグインにはコードが整理される、引き継ぎやすい等メリットがあります。ただ、時には一日も速いリリース、達成感が大事なタイミングもあるかと。CakePHPに慣れてから、いつでもさくっと使えますから。

Pluginの導入

composer, git clone, コピペ何でもいいのでapp/Plugin内にPluginのファイルを導入します。
app/Config/bootstrap.php内でプラグインをloadするのを忘れずに。

CakePlugin::load('Search');

Searchプラグインの概要

SearchプラグインPrgComponentSearchableBehaviorから成ります。

PRG ComponentはユーザーがPOSTした値を処理し、検索条件を保持しつつGETでも使える形に処理します。

これはPRG(Post Redirect Get)パターンというものに沿った処理で、
POSTのメリット(少しだけセキュアなのと文字数制限のなさ)
GETのメリット(ブラウザリロード時に問題が起きない、ブックマーク可能)
を組み合わせて利用するものです。

SearchableBehaviorは、渡された値を元に、find()やpaginate()で使える条件配列を返します。

以下具体的なコードと共に、Controller, Model, Viewそれぞれでやることを記述します。

Controllerでやること

<?php
class ArticlesController extends AppController {

  // 1.componentを定義します
  public $components = array(
        'Search.Prg' => array(
        'commonProcess' => array(
          'paramType' => 'querystring',
          'filterEmpty' =>  true,
        ),
  );

  // 2.presetVarsを定義します
  public $presetVars = true;

  public function find() {
  // 3.Prg->commonProcessを呼び出します
    $this->Prg->commonProcess();
  // 4.Searchable BehabiorparseCriteriaで、検索条件を取得します
    $this->Paginator->settings['conditions'] = $this->Article->parseCriteria($this->Prg->parsedParams());
    $this->set('articles', $this->Paginator->paginate());
  }
}

サンプルを元に、色々と改変しています。

1.PrgComponentの読み込み

いつもこの設定で使っています。お好みで。

          'paramType' => 'querystring',

URLがクエリストリング形式(例 /search?blog_id=3&author_id=5)になります
          'filterEmpty' =>  true,
inputに値が入力されなかった場合、クエリストリングにその項目を含みません
例 /search?blog_id=&author_id=5 が /search?author_id=5 に

2.presetVarsの設定

この後Model内のfilterArgsというパラメータで、各値の形式等を設定します。presetVarsではそれを上書きできます。ユーザーの入力がない場合の、デフォルト値を設定したりも。
現在は設定しなくて大丈夫なので、特殊なことをしなければ例のようにtrueに、または記述しなくてOKです。

3.Prg->commonProcessを呼び出す

特に考えることはありません。ただ呼び出すだけです。

ちなみにSearchPluginの動作を確認する際は、
1.POSTされたデータ処理の流れがうまくいっているか
2.ただしい条件配列が作成されているか
の2点の切り分けがポイントです。Controllerの全て、ModelのpresetVars設定、Viewのフォームが揃ったら、一度POSTされたデータの処理がうまくいっていることを確かめてみましょう。
例えばblog_idを指定してsubmitボタンを押した時、search?blog_id=3のようなURLに遷移すれば、後はpaginate()に適切な条件配列を渡すだけです。

4.SearchableBehavior parseCriteriaを呼び出す

parseCriteriaは、find()、paginate()で使える条件配列を作成します。

  array(
    'Article.blog_id' => 2,
    'Article.title' => 'タイトル',  
  );
こんなのが返ります。これを条件配列を渡すとしぼりこまれます。
  $conditions = $this->Article->parseCriteria($this->Prg->parsedParams());

  $articles = $this->Article->find('all', array(
    'conditions' => $conditions,
  ));
  $articles = $this->Paginator->paginate('Article',
    $conditions
  );

Modelでやること

<?php
    
  class Article extends AppModel {
    // 1.SearchableBehaviorを読み込む
    public $actsAs = array(
        'Search.Searchable'
    );

    public $belongsTo = array(
        'User',
        'Blog',
        'Author',
    );

    public $hasAndBelongsToMany = array(
        'Tag' => array(
            'with' => 'Tagged'
        )
    );

    // 2.filterArgsを設定する
    public $filterArgs = array(
        'title' => array(
            'type' => 'like'
        ),
        'status' => array(
            'type' => 'value'
        ),
        'keyword' => array(
            'type' => 'like',
            'field' => array('Article.title', 'Blog.title', 'Author.name')
        ),
        'tags' => array(
            'type' => 'query',
            'method' => 'findByTags',
        ),
    );

    // 3.独自メソッドを定義する
    public function findByTags($data = array()) {
      $articleIds = $this->Tagged->find('list', array(
        'fields' => 'Tagged.article_id',
        'conditions' => array('Tagged.tag_id' => $data['tags']),
      ));
      $condition[1] = array(
        'Article.id' => $articleIds,
      );
      return $condition;
    }
}

1.SeachableBehaviorの読み込み

読み込むだけです。

2.filterArgsの定義

検索で使う項目ごとに1.どのフォームの値を2.どのフィールドに対して3.どのような方法で行うかを定義します。
まず、配列の第一階層のキー(例ではtitle,blog_id,keyword,tags)は項目名になります。
対応する配列内では、基本的に以下3項目を指定します。
formField 1.を定義するもので、対応するフォームのフィールド名(name attributeの末尾の値)を指定
field 2.を定義するもので、モデルのフィールドを指定(例, title, Blog.titleなど)
type 3.を定義するもので、検索する方法を指定
1.と2.を指定しない場合、先ほどの項目名がデフォルト値になります。
例えば、Articleモデルのフォーム内で、titleという項目に入力された値を受け、Articleモデルのtitleを検索する場合、typeだけを指定すれば良いです。
やりたいことによっては、上記以外の項目の指定が必要になります。

fieldについて
fieldでは、該当モデルにContainされている、またはリレーションされているモデルのフィールドも指定することができます。
typeについて
typeに関しては、元々定義されているメソッドを使う場合と、自分で定義するメソッドを使う場合があります。

元々定義されているものではvalue, likeを良く使います。
valueは値を完全一致で検索します。selectなど単一選択、checkboxなど複数選択、textなど文字列全てに対応します。
likeは文字列の部分一致検索です。 WHERE 'Article'.'title' = '%タイトル%' みたいな感じ。例のように複数フィールドをまとめて検索も可能です。

あとは比較演算子の使用や、前方/後方一致、between等が定義されていますが、割愛します。

自分で定義したメソッドを使う
この場合は、typeにquery、subqueryを指定します。
僕はqueryタイプしか使わないのでそちらの解説を。

queryタイプは定義したメソッド内で、フォームで入力された値を受け取り、条件配列を作成するものです。
上の例のように、filterArgs内でmethodという項目でメソッド名を指定し、モデルのメソッドとして実装します。

    public function methodName($data = array()) {
      //処理
      $condition = array(/* 条件配列 */);
      return $condition;
    }
フォーマットは上記です。
値の受け取り
$data['フォームのキー']という形で値を受け取れます。filterArgsで指定した項目以外の値も使用できます。
返り値と条件配列の挙動
メソッド内で条件配列の要素を作成し、返り値として渡すような形です。
返り値として渡された条件配列の要素は、前のフィールドまでで作成された条件配列にどんどんマージされていきます。
注意が必要なのは、要素間で条件指定するフィールドが被った際の挙動です。
この場合、先に定義された要素は上書きされます。array_mergeされているような挙動ですね。

これは防ぐ方法があって、例のように返り値にする条件配列の要素に、通し番号を振ると上書きされません。

//独自メソッドの返り値として
array('Article.blog_id' => 1); と array('Article.blog_id' => 2); があった場合 
前者は上書きされ array('Article.blog_id' => 2) の要素だけ適用

array(1 => array('Article.blog_id' => 1)); と array(2 => array('Article.blog_id' => 2)); があった場合
上書きされず、両要素がAND条件で適用
といった形になります。

AND条件とOR条件
検索に限らずModelの一般論ですが、条件配列の中身は
・1つのフィールドの条件として複数の値を指定した場合はOR条件
・異なるフィールド間ではAND条件
として処理されます。
異なるフィールド間でOR条件として処理したい場合は、キーにORをつけた配列を作成します。

array(
  'OR' => array(
    'Article.blog_id' => 2,
    'Article.title' => 'タイトル',
  ),
);  

Viewでやること

echo $this->Form->create();
echo $this->Form->input('title', array(
        'div' => false
    )
);
echo $this->Form->input('blog_id', array(
        'div' => false,
        'options' => $blogs
    )
);
echo $this->Form->input('keyword', array(
        'div' => false,
    )
);
echo $this->Form->input('tags', array(
        'div' => false,
        'multiple' => 'checkbox',
    )
);
echo $this->Form->submit(__('Search'), array(
        'div' => false
    )
);
echo $this->Form->end();
普通にフォームを定義するだけです。

Form->create

querystring指定をしていれば、本家サンプルのように$this->param['pass']を指定する必要はありません。

multipleの場合の値

例のtagsのように複数選択可能なフォームにすると、フォームから渡される値が配列になり、クエリストリングもtags[]=1(tags%5B0%5D=1)のような形になります。

データの設計

最後にですが、DB設計の段階で、検索を意識してスキーマを定義することが非常に重要だと思います。
イメージとしては、filterArgsができるかぎりvalueとlikeだけですむように設計段階から考えていくことが重要かと。