CakePHP3で自作アプリを作ってみた part3 〜検索機能の実装〜


7回に渡って記載していきます。
**CakePHP3で自作アプリを作ってみた シリーズ**
今回part3では

* 検索機能

らへんを解説したいと思います。

ブツはこちら公開済み。
https://cocktail-com.herokuapp.com/

データ登録が間に合わなかったので悪しからず。。。
少しづつ登録します。

今までの記事
**CakePHP3で自作アプリを作ってみた part1 〜イントロ〜**
https://qiita.com/m-hatano/items/61392c33fdbd49376747

**CakePHP3で自作アプリを作ってみた part2 〜herokuでHello World!!まで〜**
https://qiita.com/m-hatano/items/79480fa380ebc49c0209

---

前回はherokuの初期設定を行って、herokuでCakePHP3を動かすまで解説しました。
ここからは自作したアプリの備忘録として検索機能を解説していこうと思います。

ここからは、備忘録として自分がわかる、思い出せるレベルで結構はしょりますので、万人にわかりやすい説明にはなっていません。（レベル感としてはpart1の作者のスキルセットなどをご参照ください。。。）
とはいえ苦労した部分も記載しますので、なるべく誰かの参考になればいいなと思って書いていきます。

## 検索機能
### 状態変化
カッコ内アクションで→の画面へ変化するイメージです。

**検索画面（検索する）→検索結果一覧画面（検索結果クリック）
→詳画面（検索条件変更）→検索結果一覧画面（検索結果クリック）→・・・以降繰り返し**

オーソドックスな検索の流れです。

### 検索対象テーブル
規約的には

* テーブルは複数形
* primary keyは「id」
* foreigh keyは「cocktail_id」のように{テーブル名単数系}_{id}


```sql
create table cocktails (
  id serial,
  name varchar(64),
  search_name varchar(64),
  glass varchar(2),
  percentage varchar(2),
  color varchar(20),
  taste varchar(2),
  processes varchar(500),
  img_url varchar(200),
  dt_create timestamp DEFAULT CURRENT_TIMESTAMP,
  primary key (id)
);
```
### ルーティング
特に設定はしません。
規約ベースだと記載量が少ないのでオススメです。
ちなみに規約で固まっているというよりは、パスに対応するアクションがあればマッピングしてくれるって感覚の方が近いです。

自動生成されたプログラムを読み解いてCakePHP3のルーティングを読み解くと、、、、
基本はリソースベースルーティングでRailsと似ていますが若干違います。
bakeで生成するコントローラを調査すると下記のようになっているため、これが推奨なのかなと。
対応するアクションがコントローラで定義されていない場合はルーティングエラーになります。

|メソッド| ルート | アクション |
|:-:|:-:|:-:|
|GET| /Cocktails/○○○○  |  ○○○○() |
|GET| /Cocktails/  |  index() |
|GET| /Cocktails/view/$id | view($id)  |
|GET POST| /Cocktails/add  | add()  |
|GET POST PUT PATCH| /Cocktails/edit/$id  | edit($id)  |
|POST DELETE| /Cocktails/delete/$id|delete($id)|

任意でルートを設定する場合は
/config/routes.phpを修正します

* /config/routes.php

```php
<?php

use Cake\Core\Plugin;
use Cake\Routing\RouteBuilder;
use Cake\Routing\Router;
use Cake\Routing\Route\DashedRoute;

Router::defaultRouteClass(DashedRoute::class);

Router::scope('/', function (RouteBuilder $routes) {

    $routes->get('/', ['controller' => 'Cocktails', 'action' => 'index']);
    $routes->put('/cocktails/edit/:id', ['controller' => 'Cocktails', 'action' => 'edit'])
        ->setPatterns(['id' => '\d+'])
        ->setPass(['id']);

    $routes->fallbacks(DashedRoute::class);
});
Plugin::routes();
```

「http://ドメイン/」でアクセスするとCocktailsController#index()が実行されるという指定と
「http://ドメイン/cocktails/edit/1」でアクセスするとCocktailsController#edit(1)が実行されるという指定をしています。

### コントローラ
* Controller/CocktailsController

```php
class ElementsController extends AppController
{
    public $paginate = [
        'limit' => 20,
        'order' => [
            'Elements.name' => 'asc'
        ]
    ];

```
命名規則に乗っ取り複数形です。
Cakeのページャーを使用するために1ページあたりの表示件数と、表示順を指定しています。

* index()

```php
    public function index()
    {
    }
```
初期表示です。何もないです。
規約に沿ってるためこれだけでindex.ctpが表示できます。

* search()

```php
    public function search()
    {
        $params = $this->request->getQueryParams();

        $cocktails = new Cocktails($params);
        $errors = $cocktails->validateForSearch();
        // エラー場合はflashをセットして元の画面へ
        if ($errors) {
            foreach ($errors as $error){
                $this->Flash->error($error);
            }
            $this->set(compact('params'));
            return $this->render('index');
        }

        $query = $this->Cocktails->fetchAllCocktails($params);
        $this->set('results', $this->paginate($query));
        $this->set(compact('params'));
        // 結果0件は元の画面へ
        if ( 0 == $cnt = $query->count()) {
            $this->Flash->error("検索結果はありません");
            return $this->render('index');
        }

        $this->Flash->set($cnt . "件ヒットしました");
    }
```
検索アクションです。

少し柔軟な作りにしたくて、Cocktailsというロジッククラス的なものを介しています。
Cocktailsクラスではバリデーションや検索のクエリビルダの組み立てを担当してもらいました。
そのおかげで(？)コントローラはなるべく薄く保っています。

バリデーションに応じたフラッシュ処理と呼び出しテンプレートの指定と、
設定したページャをここで使ってlimitの件数ずつ返却をしています。


* view()

```php
    public function view($id)
    {
        $cocktails = new Cocktails();
        $results = $cocktails->fetchCocktailDetail($id);

        $this->set('cocktail', $results['cocktail']);
        $this->set('tags', $results['cocktail']['tags']??[] );
        $this->set('cocktails_elements', $results['cocktails_elements']);
    }
```

詳細表示アクションです。

クリックしたカクテルのIDを連携してDBからカクテルの詳細を取って来ています。
joinしたりするクエリを作りたい場合、アソシエーションを設定すれば簡単に取得できます。
参考：https://book.cakephp.org/3.0/ja/orm/associations.html

今回クエリ系はCocktailsクラスに担当させて実装すると決めてやったのですが、コントローラに書いても全く問題ないです。
CakePHP3は独自のORMを実装していますが、Doctrinや他に似ているORMがいっぱいあるので、何か使ったことがあれば慣れるのは簡単かと思います。

例によって例のごとくDB定義を規約に沿って作って入れば、find + (By + {キー名})で検索できちゃいます。

注意するのはfindではクエリを作成しただけで、first(), toArray()など取得用のメソッドをクエリに対して打たなければDBへSQLが実行されないです。

```php
// アソシエーションでまとめて取得
$query = $this->Cocktails->findById($cocktail_id)->contain(['CocktailsTags', 'CocktailElements', 'Tags']);
// 実行
return $query->first();
```

### テーブル

* Model/Table/CocktailsTable.php

```php
<?php
namespace App\Model\Table;

class CocktailsTable extends Table
{
    public function initialize(array $config){

        $this->setTable('cocktails');
        $this->hasMany('CocktailsElements')
        ->setForeignKey('cocktail_id');

        $this->hasMany('CocktailsTags')
        ->setForeignKey('cocktail_id');

        $this->belongsToMany('Tags');
    }

    public function fetchAllCocktails($params){

        $query = $this->query()->contain(['Tags']);

        // 検索項目に合わせてSQLを作成
        $query->where(['1' => 1]);
　　　 　　　　　　　　　　　
　　　　　　　　　　　　　　　　~~中略~~

        $query->order(['name' => 'ASC']);

        return $query;
```

DAOクラスです。Tableクラスを継承しています
initializeで先ほど出て来たアソシエーションを設定します。

ソースははしょりまくってますが、リソースに対するテーブルクラスを作成し、
ここでfetchElementsByCocktailIdみたいにメソッドを書くと
コントローラで

```php
$results = $this->Cocktails->fetchAllCocktails($params);
```
みたいに使えます。

本当はこのCocktailsTableを継承したクラスに個別のクエリを書いた方がいいみたいな案もありました。
参考記事様。
https://qiita.com/ymm1x/items/12ced37212636b09de19

### ビュー

* Template/Layout/_layout.ctp

```html
<!DOCTYPE html>
<html>
<head>
    <?= $this->Html->charset() ?>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>
        <?= $this->fetch('title') ?>
    </title>
    <script type="text/javascript" src="/js/jquery-3.2.0.min.js"></script>
    <script type="text/javascript" src="/js/jquery.validate.min.js"></script>
    <script type="text/javascript" src="/js/bootstrap.min.js"></script>
    <?= $this->Html->meta('icon') ?>

    <link href="/css/bootstrap.min.css" rel="stylesheet">
    <?= $this->Html->css('base.css') ?>
    <?= $this->Html->css('cake.css') ?>
    <?= $this->Html->css('cocktail-com.css') ?>

    <?= $this->fetch('meta') ?>
    <?= $this->fetch('css') ?>
    <?= $this->fetch('script') ?>
</head>
<body>
    <?= $this->element('_header'); ?>
    <div class="container">
        <div class="main">
        <?= $this->Flash->render() ?>
        <?= $this->fetch('content') ?>
        </div>
    </div>
    <div class="push"></div>
    <?= $this->element('_footer'); ?>
</body>
</html>
```
デフォルトで生成されるテンプレートを少しいじって使用しています。

```html
<meta name="viewport" content="width=device-width, initial-scale=1.0">
```
レスポンシブ対応するための呪文の一部です。
その他cssやjsをインクルード（？）しています。
この辺の書き方は公式ドキュメントに詳細が記載されていますので必要であれば参照してください。

```php
<body>
    <?= $this->element('_header'); ?>
    <div class="container">
        <div class="main">
        <?= $this->Flash->render() ?>
        <?= $this->fetch('content') ?>
        </div>
    </div>
    <div class="push"></div>
    <?= $this->element('_footer'); ?>
</body>
```
body部分はヘッダーとフッターに分けて、それぞれをエレメントとして当て込んでいます。
エレメントとはviewの部分テンプレートとして要所で使用するイメージです。
Template/Element配下に置いて「<?= $this->element('_footer'); ?>」ように記載するとエレメントが記載部分に当てこまれます。

* Template/Cocktails/index.ctp
 
```php
<div class="title__wrapper">
    <h1>カクテルを検索する</h1>
</div>
<div class="cocktailSearch__wrapper">
    <form action="<?= $this->Url->build('/cocktails/search') ?>" method="get">
        <?= $this->element('Cocktails/cocktail_conditions');?>
        <button type="submit" class="btn btn-default btn-full" >検索</button>
    </form>
</div>
```

レイアウトの「<?= $this->fetch('content') ?>」の部分に、コントローラのアクションから呼ばれたindex.ctpが当てこまれます。

* Template/Element/Cocktails/cocktail_conditions.ctp
 
```html
<div class="form-group">
    <input type="text" class="name-search-input" name="name" value="<?= $params['name']??'' ?>" placeholder="カクテルの名前を入力..." />
</div>
<div class="form-group">
    <h2>グラスタイプ</h2>
    <ul class="form-input-check display-inline-ul">
        <?php foreach ($glass_list as $key => $value):?>
        <li>
            <label>
                <input type="checkbox" class="checkbox-input" name="glass[]" value="<?= $key?>" <?php if(in_array($key, $params['glass']??[])): ?> checked="checked" <?php endif; ?> />
                <span class="checkbox-span"><?= $value?></span>
            </label>
        </li>
        <?php endforeach; ?>
    </ul>
</div>

~~~ 中略 ~~~

<div class="form-group">
    <h2>タグから検索する</h2>
    <ul class="form-input-check display-inline-ul">
        <?php foreach ($tags_master as $tag):?>
        <li>
            <label>
                <input type="checkbox" class="checkbox-input" name="tag_id[]" value="<?= $tag['id']?>" <?php if(in_array($tag['id'], $params['tag_id']??[])): ?> checked="checked" <?php endif; ?> />
                <span class="checkbox-span">#<?= $tag['name']?></span>
            </label>
        </li>
        <?php endforeach; ?>
    </ul>
</div>
```
普通の検索画面なので特に説明することはありません。
入力保持をうまくやっているのと、
$glass_listはコントローラから返却せずに、viewクラスを使ってconfigを返却してます。

* /config/const.php

```php
<?php
// ユーザ定義定数
return [
    'glass' => [
        '1' => 'ショート',
        '2' => 'ロング',
        '3' => 'ロックグラス',
        '4' => 'ビールグラス',
        '5' => 'ワイングラス',
        '6' => 'その他',
    ],

~~ 略 ~~

```

 /config/bootstrap.phpに一行追加してconst.phpをロードさせます

```php
Configure::load('const');
```

* View/AppView.php

```php
class AppView extends View
{
    public $layout = '_layout';

    public function initialize()
    {
     $this->set('glass_list', Configure::read('glass'));
```

これでAppViewでreadしてsetできます。

$tags_masterは memcachedに溜め込んであるのですが、それはまた後のpartで解説します。

---

以上です。
読んでくださりありがとうございました！

すごい簡単にトバして説明してますが、悪しからず。。。

何か質問や、こうやったらいいよ。があればコメントいただけたら嬉しいです。

次回part4は

* 登録機能
* 編集機能

を解説しようと思います！

参考にさせていただいた記事
https://qiita.com/zayarwinttun/items/7afae4cc9f5388babc38
https://qiita.com/engineer_atsumi/items/c2602066a933ad9861ad