cakePHP標準のページネーションに検索機能を実装し、発生した問題点に対処した

cakePHP4にはコマンドを使うことで、ビューにページネーション機能を自動生成できます。ところが、この**ページネーション機能はあくまで単体リストに対するもの**なので、ここに検索機能を盛り込むと色々と対処しなければならない問題が発生します。ですので、これを実用レベルでシステムを起動するために、あちこちで生じる問題点を補完する作業が必要になります。

cakePHP2、cakePHP3でも参考になる部分もあるかもしれません。

# 要件定義
実証用のプログラムは以下のものとなっています（別記事にも使ったやつです）。

[cakePHPのクエリ処理が遅いときには、SQLを今一度、要確認](https://qiita.com/BRSF/items/e874d02dc83de5e6fc5f)

- 全12球団のマスコットと選手が登録されたテーブルがあり、テーブルPersonsに格納されている。
- 検索条件は、部分一致（テキストボックス）、チーム名（プルダウン）、リーグ選択（ラジオボタン）が実装されている。
- 検索条件は10件ずつ表示される。

では、この検索システムを通常実装のpaginatorを使って作っていくのですが、cakePHP4は便利なことに、CRUD機能を一発で作ってくれるコマンドがあり、それを活用して作成したのが以下の検索プログラムです。

![search1.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272191/60ab7f0e-5c16-c04c-0ffa-b02859abbb4c.jpeg)

ところが、この標準実装されるページネーション機能に、そのまま検索フォームを実装すると次々と問題が発生します。

# 問題点1:ページを遷移すると検索条件が初期化される
たとえば、フリーワード検索欄で「藤」が含まれる選手を抽出しようとします。すると、検索の最初のページこそはその検索条件に該当したデータが取得されるのですが、別ページに遷移してみると、あろうことか`検索条件が初期化されてしまい`、何も検索条件が入力されないままでそのページに遷移してしまいます。

![search2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272191/e482e357-6152-7983-65e4-7fac15aa8da3.jpeg)

このように、「藤」を含む検索条件で検索したはずなのに、検索条件が保持されていないために、ページ遷移するとそれがなかったことにされてしまいます。

## 対処法
セッションを使って検索条件を保持しておきましょう。そして、ページが遷移したときにその検索条件を読み取ります。なお、ページ遷移は`get`から取得されるので、getでリクエストを受けた場合に検索を受け取るようにします。

クエリ情報を格納した変数$sqlを記憶させ、呼び出すのもいいかも知れませんが、それだけ検索条件を変更した場合の対応が面倒です。

なお、変数`$ses`はinitializeメソッド（コンストラクタのようなもの）から取得したセッション使用のための変数です。

```php:PersonsController.php

	public function initialize() :void
	{	
		$this -> loadComponent('Flash');
		$ses = $this -> request -> getSession(); //セッション制御
        }
    //中略
    public function index()
    {
		$persons = $this -> persons;
		$t_persons = $this -> t_persons;
		$sql = $this -> sql;
		$ses = $this -> ses;
		$ar_data = [];
		if( $this -> request -> is(['put']) || $this -> request -> is(['post']) ){
			$ar_data = $this->request->getData(); //新規検索フォームの値
		}elseif($this -> request -> is(['get'] )){
			$ar_data = $ses -> read('find'); //検索条件の呼び出し
		}else{
			$ar_data = [];
		}
		if($this->request->is(['put','get','post']) ){
			$ar_where = [];
			if(isset($ar_data['find'])&& $ar_data['find'] != NULL) $ar_andwhere['p.name like'] = "%{$ar_data['find']}%";
			if(isset($ar_data['team'])&& $ar_data['team'] != NULL) $ar_andwhere['t.id'] = $ar_data['team'];
			if(isset($ar_data['league'])&& $ar_data['league'] != NULL) $ar_andwhere['t.league'] = $ar_data['league'];
			if(isset($ar_orwhere) && $ar_orwhere != NULL) $ar_where["OR"] = $ar_orwhere;
			if(isset($ar_andwhere) && $ar_andwhere != NULL) $ar_where["AND"] = $ar_andwhere;
			$sql = $t_persons -> where($ar_where);
			//var_dump($sql -> sql()); //デバッグ用
			$persons = $this -> paginate($sql);
			$ses -> write(['find'=> $ar_data]); //セッションに検索ワードを記憶させる
		}else{
			$persons = $this->paginate($this->t_persons);
		}
        $this->set(compact('persons'));
		$this->set('find',$ar_data);
		$this -> sql = $sql;
		$this -> ses = $ses;
    }

```

これで検索条件は維持されています。

![search3.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272191/1340f6b6-9896-f557-070b-e2b4a89a8f10.jpeg)

ところが、これでは検索条件がフォームに維持されていないので、何の条件で検索したのかわかりません。


# 問題点2：ページを遷移するとフォームに検索条件が保持されない
これも困った問題で、上図のように検索条件は維持されているのですが、フォームにその検索条件が表示されていません。そこで、それが表示されるように対処します。

## 対処法
検索条件をsetメソッドでビューに返し、フォームヘルパーにvalueを設定させます。

```php:PersonsController.php
        $this->set(compact('persons'));
		$this -> set('models',$ar_data); //配列$modelsとしてビューに返す
		$this -> sql = $sql;
		$this -> ses = $ses;
    }
```

そしてこのmodelsに記憶したさせた値をFormヘルパーのvalueに対応させるだけです。`$models`は$ar_dataの値が代入されているので、配列で呼び出すという点に注意してください（オブジェクトで代入させた場合は当然オブジェクトとなります。また変数名は何でも構いません）。なお、検索した場合のリクエストは`put`となります。

```php:index.php
    <?= $this->Html->link(__('新規作成'), ['action' => 'add'], ['class' => 'button float-right']) ?>
	<?= $this->Form->create($persons,['name'=>'f1']) ?>
	<?= $this->Form->input('find',['id'=>'find','value'=> $models['find']]) ?>
	<?= $this->Form->select('team',$teams,['empty'=> 'チームを選択','id'=>'sel_team','value'=>$models['team']]) ?>
	<?= $this->Form->radio('league',$leagues,['value'=> NULL,'id'=>'rd_league','class'=>'rd_league radio-inline','value'=>$models['league']]) ?>
	<?= $this->Form->button(__('検索'),['class'=>'button bt_search']) ?>
	<?= $this->Form->end() ?>
```

こうすれば検索条件が維持され、ページ遷移した後もフォームに表示されます。

![search4.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272191/b0c7cf75-7569-5e59-5ee3-9f190e69aac4.jpeg)

# 問題点3：ページが遷移した状態で検索条件を入れ替えると、先頭ページに戻らない
これが一番厄介で、検索結果に対して2ページ目を開いているときに、検索条件を入れ替えると検索結果は2ページめから表示されます。これを先頭ページから表示させる処理が必要です。

具体的な説明として、バファローズで検索した後2ページめを開きます。

![search5.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272191/f6a49796-2204-0e73-4c60-16cad6c5893f.jpeg)

このページのまま、検索条件をタイガースに変更すると…

![search6.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272191/d46f59c3-c742-e5f6-afc7-539087465f0e.jpeg)

~~なんでや！~~

検索結果のページ数（2ページ目）を引き継いでしまいました。普通、検索結果は1ページ目から表示させるものなので、これを先頭に来るように対処させないといけません。


## 対処法
paginatorヘルパーやらpaginateメソッドやら色々調べたのですが、解決方法はもっと根本的な部分でした。答えはフォームヘルパーの、`create`メソッドのactionにコントローラ名を記述するだけです。

```php:index.php
    <?= $this->Html->link(__('新規作成'), ['action' => 'add'], ['class' => 'button float-right']) ?>
	<?= $this->Form->create($persons,['name'=>'f1','action'=>'persons']) ?>
	<?= $this->Form->input('find',['id'=>'find','value'=> $models['find']]) ?>
	<?= $this->Form->select('team',$teams,['empty'=> 'チームを選択','id'=>'sel_team','value'=>$models['team']]) ?>
	<?= $this->Form->radio('league',$leagues,['value'=> NULL,'id'=>'rd_league','class'=>'rd_league radio-inline','value'=>$models['league']]) ?>
	<?= $this->Form->button(__('検索'),['class'=>'button bt_search']) ?>
	<?= $this->Form->end() ?>
```

では、気を取り直して検索条件をベイスターズに変更してみます。

![search7.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272191/5832ebd2-734d-bb75-0836-2b0f237edde6.jpeg)

しっかりと先頭ページから表示されるようになりました。

これで最低限、実用的な検索プログラムが機能するようになります。

# 応用編
## プルダウンをチェックボックスに変更する
現在、チーム名選択はプルダウンですが、複数選択できるようにチェックボックスに変更してみます。複数選択用のチェックボックス用のメソッドとして**multiCheckbox**というメソッドがあるので、これを活用します。

```php:index.php    
<?= $this->Html->link(__('新規作成'), ['action' => 'add'], ['class' => 'button float-right']) ?>
	<?= $this->Form->create($persons,['name'=>'f1','action'=>'persons']) ?>
	<?= $this->Form->input('find',['id'=>'find','value'=> $models['find']]) ?>
	<div class="wire-checkbox">
	<?= $this->Form->multiCheckbox('team',$teams,['empty'=> 'チームを選択','id'=>'sel_team','class'=>'form-check-inline','value'=>$models['team']]) ?>
	</div>
```

ちなみに、multiCheckboxメソッドで自動生成されるチェックボックスはこのような構造となっていますが、そのままだと行表示されてしまうので、checkboxクラスを`display:inline-block`で横並びに制御しておきましょう（Bootstrap実装だと、form-check-inlineで対応できます）。

```php
<div class="checkbox"><label for="sel_team-1" class="selected">
    <input type="checkbox" name="team[]" value="1" checked="checked" id="sel_team-1" empty="チームを選択" class="form-check-inline">Hawks</label>
</div>
```

また、コントローラ側の制御ですが、in条件に対応させるには以下のようにします。注意点は一般的なSQLだと

**t.id in (1,2,3)**

となるのですが、cakePHPのwhereメソッドの場合、この場合だと単体だと問題ないのですが、複合条件となった場合、括弧内の先頭の値しか対応しなくなるので、

**t.id in ([1,2,3])**

とオブジェクトで囲む必要があるようです。

- [CakePHP3のORMにおけるIN句とNOT句の記述方法](https://qiita.com/morisuke/items/e466d2ab360ab5646e9a)

```php:PersonsController.php
if(isset($ar_data['team'])&& $ar_data['team'] != NULL) $ar_andwhere['t.id in'] =  $ar_data['team'];
```

ですが、これは却って好都合なので、他のオブジェクトと同様の検索条件で値を代入することができます（implode関数での展開が不要）。しかも、値の保持もvalueに同じ配列を記述するだけなので非常に便利です。

![search8.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272191/fb41b152-5697-b074-1c15-e274472c75f6.jpeg)

このように検索条件に吉田を含む、かつバファローズとスワローズで検索ができます。

## フリーワード検索をOR条件にする
これを応用すれば、フリーワード検索もOR検索ができます。そのためには完全一致条件なら`in`が使えるのですが、like検索の場合inにしたところで意味がありません。なので同様にオブジェクトに代入していく必要があるのですが…

このやり方だと、キーが同じなのでオブジェクトが上書きされてしまい失敗（後の条件しか適用されない）します。

```php:php
$ar_words = explode("　",$ar_data['find']); //全角スペース区切りで配列に格納
foreach($ar_words as $word){
    $ar_orwhere['p.name like'] = "%{$word}%"; //キーが同じなので検索条件が上書きされてしまう。
}
```

なので、次のように対応する必要があります。

```php:personsController.php
$ar_words = explode("　",$ar_data['find']); //全角スペース区切りで配列に格納
foreach($ar_words as $word){
    $ar_orwords['p.name like'] = "%{$word}%";
    $ar_orwhere[] = $ar_orwords; //逐次、検索条件を格納していく。キーはなし（インデックス）で問題はない。
}
```

これでOR条件（高橋か髙橋を含む）に対応させることができます。

![search9.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272191/ebf933db-6753-aeb5-afd3-83690b08cc9f.jpeg)

無論、AND条件とOR条件の複合条件にも対応しています（ライオンズかジャイアンツかカープ　かつ　高橋か髙橋を含む）。

![search10.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272191/849a4a5f-e622-7111-911b-a9fcc984d751.jpeg)