PHP(Laravel) 空白を含む検索

# はじめに
空白区切りで複数の検索をかけれるようにしたい。

【例】　"Ruby PHP JavaScript" → `Ruby` `PHP` `JavaScript`

# 条件
```bash
mac OS "11.2.3 Big Sur"
Laravel Framework "6.20.27"
PHP "8.0.7"
```

# 最終的なコード
`Controller`と`Model`に分かれています。
Controllerで使用している関数をModelの方で定義しています。

```php:Controllers/SearchController.php
class SearchController extends Controller
{

    public function __construct(Search $search)
    {
        $this->search = $search;
    }


    public function index(Request $request)
    {
        $query = Recipe::query();

        // エスケープ処理
        $keyword = $this->escape($request->input('keyword'));
        
        // 空白区切りで文字列を分割
        $keywords = $this->search->pregSplit($keyword);

        // 検索処理
        $recipes = $this->search->searchQuery($query, $keywords);

        return view('search.index', [
            'recipes' => $recipes,
            'keywords' => $keywords,
            'searchCount' => $this->search->searchCount($recipes),
        ]);
    }


    /**
     * 検索欄に文字が入力された際、str_replaceを実行する
     * @param string $value
     * @return string $value
     */
    private function escape(string $value = null)
    {
        if(!$value) {
            return $value;
        }

        return str_replace(
            ['\\', '%', '_'],
            ['\\\\', '\\%', '\\_'],
            $value
        );
    }
}
```

```php:Models/Search.php
class Search extends Model
{

    /**
     * 空白区切りで検索欄に入力された場合
     * @param string $keyword
     * @return Array
     */
    public function pregSplit($keyword)
    {
        // 全角空白→半角空白
        $keyword = mb_convert_kana( $keyword, "s" );
        return preg_split('/[\p{Z}\p{Cc}]++/u', $keyword, -1, PREG_SPLIT_NO_EMPTY);
    }


    /**
     * 検索処理
     * @param \Illuminate\Database\Eloquent\Builder $query
     * @var string[]|false $keywords
     * @return \Illuminate\Database\Eloquent\Builder $query
     */
    public function searchQuery($query, $keywords = null)
    {
        if(!empty($keywords)) {
            foreach ($keywords as $keyword) {
                $query->where('title', 'like', "%$keyword%")
                    ->orWhere('cook_time', 'like', "%$keyword%")
                    ->orWhere('ingredients', 'like', "%$keyword%")
                    ->orWhereHas('tags', function($query) use ($keyword) {
                        $query->where('name', 'like', "%$keyword%" );
                    })
                    ->orWhereHas('mealType', function($query) use ($keyword) {
                        $query->where('name', 'like', "%$keyword%" );
                    })
                    ->orWhereHas('mealClass', function($query) use ($keyword) {
                        $query->where('name', 'like', "%$keyword%" );
                    });
            }
        }
        return $query->get()->sortByDesc('created_at')
        ->load('user', 'stocks', 'tags', 'mealType', 'mealClass');
    }


    /**
     * 検索ヒット数カウント
     * @param mixed $recipes
     * @return int
     */
    public function searchCount($recipes)
    {
        return $recipes->count();
    }
}
```

# 各コードの流れ
```php:Controllers/SearchController.php
class SearchController extends Controller
{

    public function __construct(Search $search)
    {
        $this->search = $search;
    }


    public function index(Request $request)
    {
        $query = Recipe::query();

        // ① エスケープ処理
        $keyword = $this->escape($request->input('keyword'));
        
        // ② 空白区切りで文字列を分割
        $keywords = $this->search->pregSplit($keyword);

        // ③ 検索処理
        $recipes = $this->search->searchQuery($query, $keywords);

        return view('search.index', [
            'recipes' => $recipes,
            'keywords' => $keywords,
            'searchCount' => $this->search->searchCount($recipes),
        ]);
    }


    /**
     * 検索欄に文字が入力された際、str_replaceを実行する
     * @param string $value
     * @return string $value
     */
    private function escape(string $value = null)
    {
        if(!$value) {
            return $value;
        }

        return str_replace(
            ['\\', '%', '_'],
            ['\\\\', '\\%', '\\_'],
            $value
        );
    }
}
```
### ① エスケープ処理
① の`escape()`は下の方に定義してあるように、文字の中に怪しい記号が入っていないかを調べています。
もし指定している記号`\\, %, _`があれば`\\\\, \\%, \\_`と置き換えてくれています。

https://www.php.net/manual/ja/function.str-replace.php

### ② 空白区切りで文字列を分割

```php
$keywords = $this->search->pregSplit($keyword);


// pregSplit()の内容
    /**
     * 空白区切りで検索欄に入力された場合
     * @param string $keyword
     * @return Array
     */
    public function pregSplit($keyword)
    {
        // 全角空白→半角空白
        $keyword = mb_convert_kana( $keyword, "s" );
        return preg_split('/[\p{Z}\p{Cc}]++/u', $keyword, -1, PREG_SPLIT_NO_EMPTY);
    }
```
まず、空白区切りが`全角`であれば`半角`にしてあげる。

```php
$keyword = mb_convert_kana( $keyword, "s" );
```
https://www.php.net/manual/ja/function.mb-convert-kana.php

全て半角区切りにした次に、空白ごとに文字を分割してその値を返す。

```php
preg_split('/[\p{Z}\p{Cc}]++/u', $keyword, -1, PREG_SPLIT_NO_EMPTY);
```
https://www.php.net/manual/ja/function.preg-split.php

https://qiita.com/mpyw/items/a704cb900dfda0fc0331

https://qiita.com/fallout/items/a13cebb07015d421fde3

https://gray-code.com/php/remove-space-of-strings/

### ③ 検索処理
空白処理を行い、単語のみとなったため最後に検索をかけてヒットしたものを表示。

```php
$recipes = $this->search->searchQuery($query, $keywords);


// 検索処理
    public function searchQuery($query, $keywords = null)
    {
        if(!empty($keywords)) {
            foreach ($keywords as $keyword) {
                $query->where('title', 'like', "%$keyword%")
                    ->orWhere('cook_time', 'like', "%$keyword%")
                    ->orWhere('ingredients', 'like', "%$keyword%")
                    ->orWhereHas('tags', function($query) use ($keyword) {
                        $query->where('name', 'like', "%$keyword%" );
                    })
                    ->orWhereHas('mealType', function($query) use ($keyword) {
                        $query->where('name', 'like', "%$keyword%" );
                    })
                    ->orWhereHas('mealClass', function($query) use ($keyword) {
                        $query->where('name', 'like', "%$keyword%" );
                    });
            }
        }
        return $query->get()->sortByDesc('created_at')
        ->load('user', 'stocks', 'tags', 'mealType', 'mealClass');
    }
```


https://teratail.com/questions/344976?link=qa_same_tagged_pc_sidebar

# おわり
正規表現あたりが今だに慣れないので、少しずつ慣れていきたい。