Laravel標準のページネーションに検索機能を実装し、厄介な問題点に対応した

PHPフレームワークにはだいたいページネーション用のメソッドが標準実装されています。しかし、それらはあくまで単体テーブルに対するセパレーションでしかないため、そこに検索機能を盛り込んだ場合、検索結果がリセットされる現象が起きます。

[別記事にてcakePHP4の対処法を記述した](https://qiita.com/BRSF/items/4b1017be88a8b8a03744)のですが、ではLaravelだとうまく行くのか試してみたところ、LaravelはLaravelで色々と厄介な問題を抱えていることがわかったので、それにも対応していきます。

なお、記事はLaravel7での検証となっていますが、Laravel5以降なら対応できるはずです。また、getで受け渡すなら簡単にできますが、本記事はpostで検索条件を受け渡しています。

## 要件定義
音楽情報を格納したテーブルで検索条件にはそれぞれタイトル検索（テキストボックス）、リリース年検索（ラジオボタン）、そしてアーティスト名（プルダウン）で表示されています。

![laravel1.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272191/f0d03f3f-4303-2577-0fb6-6da02e556552.jpeg)

このような検索プログラムにページャーを実装してみました。また、使用ファイルは

- コントローラ名 ArticlesController.php
- ビュー名 index.blade.php

となっています。


## 問題点１：検索結果が保持されない
ページネーションはあくまでテーブルのセパレート機能である限りは検索条件まで保持してくれません。そこで、検索結果を保持させる対応が必要になります。

### 対処法
検索条件をセッションで保持させます。Laravelでのセッションの方法は色々ありますが、今回は同一メソッド内でやりとりするだけなので`$request -> session()`で間に合います（コントローラをまたぐ場合はsessionヘルパ必須です）。

セッションに値を保持（配列、オブジェクトでも可能）

```php:php
$this -> $request -> session() -> put('キー',値);
```

保持した値を取得

```php:php
$this -> $request -> session() -> get('キー');
```

セッション保持には次の点に注意しましょう。
**注意点：セッション保持はreturn view()メソッドの前に行う**
Laravelは一度return view()を行うと、そこでコントローラの処理が返されてしまいますので、その後の記述は全て無効となります。したがって、セッション保持はreturn以前に行う必要があります。

```PHP:PHP
    public function index(Request $request)
    {
        //$articles変数にArticleモデルから全てのレコードを取得して、代入
		$articles = [];
		//検索
		$ar_find = $request -> session()->get('find');
		$keyword = ($request -> input('keyword') !== NULl )? $request -> input('keyword'): $ar_find['keyword']; //検索キーワード
		$released = ($request -> input('released') !== NULl )? $request -> input('released') : $ar_find['released']; //リリース年
		$artist_nm = ($request -> input('artist_nm') !== NULL )? $request -> input('artist_nm') : $ar_find['artist_nm']; //選択アーティスト
		$released = $ar_find['released']; //検索キーワード
		$artist_nm = $ar_find['artist_nm']; //検索キーワード
		$keyword = $ar_find['keyword']; //検索キーワード	
		$request -> session() -> forget('find');
		$query = DB::table('articles') -> where('title','like','%'.$keyword.'%')
		 -> where('released','like',$released.'%')
		 -> where('artist_nm','like',"%{$artist_nm}%");
		//var_dump($query->toSql());
		$articles = $query -> paginate(5);
		$artists = Article::select(['artist_nm'])-> groupby('artist_nm') -> get();
                //return前に保持させること
		$request ->session() -> put('find',[
														'keyword'=>$keyword,
														'released'=>$released,
														'artist_nm'=>$artist_nm,
													]
		);
		return view('articles.index') -> with([
			'articles' => $articles, //検索結果
			'artists' => $artists, //初期プルダウンメニュー
			'keyword' => $keyword,//フリーワード
			'released' => $released, //リリース年
			'sel' => $artist_nm,//選択アーティスト
		]);
    }
```

## 問題点２：検索条件をリセットしたときの対応
検索結果を保持させ、もしも検索結果を持っている場合はリクエストのフォーム情報を取得し、そうでない場合はセッションの値を保持させることを想定して次のようにプログラムを書いたとします。

```PHP:PHP
$released = ($request -> input('released') !== NULL)? $request -> input('released'): $ar_find['released'];
```

ところが、このように対応すると困った問題が発生します。もし、最初は1995年で検索したら、リリース情報が表示されます。次にこのリリース年を「なし」にすると、今度`input('released')`がNULLのために、セッション情報を取得してしまいます。

なぜ、このようなことが起こるのでしょうか？

## 原因と対応策：Laravelのルート設定
cakePHPの場合、submitはPOST、そうでない場合はGETと自動で振り分けてくれたので、うまく検索条件の振り分けができたのですが、**Laravelの場合は手動で設定しないと全部がGETで振り分けられてしまう**仕様となっています。また、フォームで値が入力されなかった場合はNULLが代入されてしまうので、**検索条件ありでセッションありの場合と検索条件なしでセッションありの判別ができない**のです。かといってダミーの値を代入してしまうと、SQL構築のときに困ります。

そこで、submitボタンを押した場合はPOST、ページャーのリンクを押した場合はGETとメソッドを振り分ける必要があるのですが、それにはroutesフォルダ直下にあるweb.phpに以下のルート設定を追記する必要があります。

```PHP:web.php
Route::resource('articles', 'ArticlesController');
Route::post('articles', 'ArticlesController@index'); //対象のビューにPOSTを適用させる
Route::post('articles/store', 'ArticlesController@store'); //新規登録ページ用の制御
```

このルート設定を行うことによって、指定したコントローラとビューにPOSTが適用されます。その後、formタグのmethodプロパティにpost設定を行うことで、ようやくpostが有効になります。

```PHP:index.blade.php
	<form action="{{url('articles')}}" method="post">
```

あとはコントローラでmethodプロパティを振り分けられるようにしましょう。メソッドを取得するには

**$request -> method()**

で簡単に取得できます。

```php:ArticlesController.php
	$method = $request -> method(); //メソッドを取得
	$ar_find = $request -> session()->get('find');
	if($method == "POST"){
		$keyword = $request -> input('keyword');
		$released = $request -> input('released');
		$artist_nm = $request -> input('artist_nm');
	}elseif($method == "GET"){
		$released = $ar_find['released']; //検索キーワード
		$artist_nm = $ar_find['artist_nm']; //検索キーワード
		$keyword = $ar_find['keyword']; //検索キーワード
	}	
	$request -> session() -> forget('find');
```

## 検索結果を表示させる
LaravelではcakePHPのフォームヘルパーのようなものは標準実装されていない(追加実装できるライブラリはあります)なので、ゴリ押しで書いていきます。ただ、同じ記述を繰り返さずにループを活用していけばスリムに記述ができます。そして、ラジオボタンはこうやってif文を埋め込み、条件で`checked`プロパティを表示させれば大丈夫です（後述しますが、チェックボックスでも対応できます）。

```PHP:index.blade.php
	<form action="{{url('articles/')}}" method="post">
		{{ csrf_field() }}
		<dl class="dl_works">
		<dt><label for="keyword">楽曲名</label></dt><dd><input type="text" name="keyword" value="{{ $keyword }}" placeholder="部分一致検索できます"></dd>
		<dt><label for="released">リリース年</label></dt>
		<dd>
			<label><input type="radio" name="released" value="" @if($released == "") checked @endif>なし</label>
			@for($year = 1991 ; $year < 2002 ; $year++ )
			<label for="released" ><input type="radio" name="released" value="{{$year}}" @if($released == $year) checked @endif >{{$year}}</label>
			@endfor
		</dd>
		<dt><label for="artist_nm">アーティスト名</label></dt>
		<dd>
			<select name="artist_nm" class="sel">
				<option value=""> -- </option>
				@foreach( $artists as $artist)
					<option value="{{$artist -> artist_nm}}" @if($sel == $artist -> artist_nm ) selected @endif>{{$artist->artist_nm}}</option>
				@endforeach
			</select>
		<button type="submit" class="btn btn-success">検索</button>
		</dd>
		</dl>
	</form>
```




## ページャーに補足する
あとはページャーにページ情報を補足します。いろいろな情報表示用のメソッドが用意されているので、それを駆使していきます。

```php:index.blade.php
<article>
	<p>{{$articles -> links() }}</p><!-- ページネーション -->
	<p>全{{$articles -> lastPage()}}ページ中、{{$articles -> currentPage()}}ページめを表示。全{{$articles -> total()}}件中@if($articles -> firstItem() != $articles -> lastItem() ) {{$articles -> firstItem()}}件目から{{$articles -> lastItem()}}件目まで表示 @else {{$articles -> firstItem()}}件目を表示 @endif</p>
</article>
```

今回、使用している情報表示用のメソッドは以下の通りです。

| メソッド | 効果 |
|:--|:--|
|$page -> lastPage() |検索対象となる全ページ   |
|$page -> currentPage() |現在表示しているページ   |
|$page -> total()   |検索対象となる全件数   |
$page -> firstItem()   |現在表示しているデータの先頭件数   |
|$page -> lastLtem()   |現在表示しているデータの末尾件数   |

これで基本的な操作が問題なくできるはずです。

![Laravel2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272191/7a4e08e6-3932-759a-1656-5907e60f25c3.jpeg)

# 応用編
## 検索機能にチェックボックスを実装する
では、Laravelでもチェックボックスを制御してみましょう。パッと見、ラジオボタンをそのままチェックボックスに変更できそうなので、一旦は次のように変更します。

```php:index.blade.php
			@for($year = 1991 ; $year < 2002 ; $year++ )
			<label for="released" ><input type="checkbox" name="released[]" value="{{$year}}" @if($released == $year) checked @endif >{{$year}}</label>
			@endfor
```

このように、チェックボックスの場合は選択なしのフォームは不要なので削除し、nameプロパティを配列にしておきます。

### コントローラの制御（SQL構築部分）
では、引き続きコントローラを調整してみます。#$releasedに代入されるのは、今度は配列なので、whereメソッドも配列が代入されるようにしましょう。そして、このように`foreach`で分解して、随時メソッド処理を施していく処理が思いつきますが…or条件が入れ子にならずに失敗します。

```php:mistake.php
		$query = DB::table('articles');
		$query -> where('title','like','%'.$keyword.'%')
		-> where('artist_nm','like',"%{$artist_nm}%");
		foreach($ar_released as $released){
			$query-> orwhere('released','like',$released.'%');
		}

//このようになる
 "select * from `articles` where `title` like ? and `artist_nm` like ? or `released` like ? or `released` like ?"
```

そこでand( A or B)と入れ子条件の処理を施す方法を調べていると、[こんな記事](https://qiita.com/Hwoa/items/542456b63e51895f9a55)があったので、それを応用してクロージャを用い、適宜メソッドを追加できるように対応しました（直前に、is_countableで制御しているのは、クロージャでNULLの場合foreach文でエラーを起こすからです）。

```php:ArticlesController.php
		if(is_countable($ar_released)){
			$query -> where(function($query) use(&$ar_released){
				foreach($ar_released as $released){
					$query-> orwhere('released','like',$released.'%');
				}
			});
		}
```

### ビューの制御
ビューの制御は配列が代入されるので、それならば、配列の有無をチェックするだけで制御ができました（in_array関数で第三引数を代入していないのは、型が一致していないためです。またこちらの判定文にもis_countable関数を入れておかないとin_array関数にNULLが代入されたときにエラーが起きます）

```php:index.blade.php
			@for($year = 1991 ; $year < 2002 ; $year++ )
			<label for="released" ><input type="checkbox" name="released[]" value="{{$year}}" @if(is_countable($released) && in_array($year,$released)) checked @endif >{{$year}}</label>
			@endfor
```

※is_countable関数はPHP7.3以降で対応します。それ以前はcount関数を使って判別してください（isset関数はNULLでもtrueを返してしまうので、それを用いる場合は必ずNULL判定文も併記してください）。

## キーワード検索をOR検索にする
では、ここも同じようにOR検索に制御していくのですが、Laravelの場合はメソッドで制御できるので、上の方法がそのまま応用できます。ただ、検索ワードはチェックボックスの時と違って、表示させるのはそのままでいいので、検索条件だけ区切れば大丈夫です。

```php:ArticlesController.php
$ar_keyword = explode("@",preg_replace("[　|\s]","@",$keyword)); //空白を特定のセパレーションにしてから配列に格納する
if(is_countable($ar_keyword)){
	$query -> where(function($query) use($ar_keyword){
		foreach($ar_keyword as $word){
			$query -> orwhere('title','like','%'.$word.'%');
		}
	});
}
```

これで、キーワードの中身だけもOR検索となります。このように、Laravelで入れ子で条件を付与する場合はクロージャを用いると楽にSQLを作成できます。