Магазин на Yii2, часть 15. Поиск по каталогу товаров, часть первая

05.07.2019

Теги: Web-разработкаYii2ИнтернетМагазинКаталогТоваровПоискПрактикаФреймворкЧПУ

Какой каталог товаров без поиска? Тем более, что и форма в шаблоне предусмотрена. Давайте для начала реализуем самый простой вариант с использованием LIKE. А потом немного усложним — добавим в SQL-запрос расчет релевантности и выполним редирект после отправки формы — чтобы сформировать краcивые URL.

Простой вариант

Начнем с layout-шаблона views/layouts/main.php, где у нас расположена форма поиска:

<div class="col-sm-4">
    <form method="get" action="<?= Url::to(['catalog/search']); ?>" class="pull-right">
        <div class="input-group">
            <input type="text" name="query" class="form-control" placeholder="Поиск по каталогу">
            <div class="input-group-btn">
                <button class="btn btn-default" type="submit">
                    <span class="glyphicon glyphicon-search"></span>
                </button>
            </div>
        </div>
    </form>
</div>

Добавим метод actionSearch() в контроллер CatalogController:

<?php
namespace app\controllers;

use app\models\Category;
use app\models\Brand;
use app\models\Product;
use yii\web\HttpException;
use Yii;

class CatalogController extends AppController {

    /* ... */

    /**
     * Результаты поиска по каталогу товаров
     */
    public function actionSearch($query = '', $page = 1) {

        $page = (int)$page;

        // получаем результаты поиска с постраничной навигацией
        list($products, $pages) = (new Product())->getSearchResult($query, $page);

        // устанавливаем мета-теги для страницы
        $this->setMetaTags('Поиск по каталогу');

        return $this->render(
            'search',
            compact('products', 'pages')
        );
    }

}

В класс модели Product добавим метод getSearchResult():

<?php
namespace app\models;

use Yii;
use yii\data\Pagination;
use yii\db\ActiveRecord;

class Product extends ActiveRecord {

    /* ... */

    /**
     * Результаты поиска по каталогу товаров
     */
    public function getSearchResult($search, $page) {
        $search = $this->cleanSearchString($search);
        if (empty($search)) {
            return [null, null];
        }

        // пробуем извлечь данные из кеша
        $key = 'search-'.md5($search).'-page-'.$page;
        $data = Yii::$app->cache->get($key);

        if ($data === false) {
            // данных нет в кеше, получаем их заново
            $query = self::find()->where(['like', 'name', $search]);
            // постраничная навигация
            $pages = new Pagination([
                'totalCount' => $query->count(),
                'pageSize' => Yii::$app->params['pageSize'],
                'forcePageParam' => false,
                'pageSizeParam' => false
            ]);
            $products = $query
                ->offset($pages->offset)
                ->limit($pages->limit)
                ->asArray()
                ->all();
            // сохраняем полученные данные в кеше
            $data = [$products, $pages];
            Yii::$app->cache->set($key, $data);
        }

        return $data;
    }

    /**
     * Вспомогательная функция, очищает строку поискового запроса с сайта
     * от всякого мусора
     */
    protected function cleanSearchString($search) {
        $search = iconv_substr($search, 0, 64);
        // удаляем все, кроме букв и цифр
        $search = preg_replace('#[^0-9a-zA-ZА-Яа-яёЁ]#u', ' ', $search);
        // сжимаем двойные пробелы
        $search = preg_replace('#\s+#u', ' ', $search);
        $search = trim($search);
        return $search;
    }

}

Теперь создадим view-шаблон views/catalog/search.php:

<?php
/*
 * Страница результатов поиска по каталогу, файл views/catalog/search.php
 */

use app\components\TreeWidget;
use app\components\BrandsWidget;
use yii\helpers\Html;
use yii\helpers\Url;
use yii\widgets\LinkPager;
?>

<section>
    <div class="container">
        <div class="row">
            <div class="col-sm-3">
                <div class="left-sidebar">
                    <h2>Каталог</h2>
                    <div class="category-products">
                        <?= TreeWidget::widget(); ?>
                    </div>

                    <h2>Бренды</h2>
                    <div class="brand-products">
                        <?= BrandsWidget::widget(); ?>
                    </div>
                </div>
            </div>

            <div class="col-sm-9">
                <?php if (!empty($products)): ?>
                    <h2>Результаты поиска по каталогу</h2>
                    <div class="row">
                        <?php foreach ($products as $product): ?>
                            <div class="col-sm-4">
                                <div class="product-wrapper text-center">
                                    <?=
                                    Html::img(
                                        '@web/images/products/medium/'.$product['image'],
                                        ['alt' => $product['name'], 'class' => 'img-responsive']
                                    );
                                    ?>
                                    <h2><?= $product['price']; ?> руб.</h2>
                                    <p>
                                        <a href="<?= Url::to(['catalog/product', 'id' => $product['id']]); ?>">
                                            <?= Html::encode($product['name']); ?>
                                        </a>
                                    </p>
                                    <a href="#" class="btn btn-warning">
                                        <i class="fa fa-shopping-cart"></i>
                                        Добавить в корзину
                                    </a>
                                    <?php
                                    if ($product['new']) { // новинка?
                                        echo Html::img(
                                            '@web/images/home/new.png',
                                            ['alt' => 'Новинка', 'class' => 'new']
                                        );
                                    }
                                    if ($product['sale']) { // распродажа?
                                        echo Html::img(
                                            '@web/images/home/sale.png',
                                            ['alt' => 'Распродажа', 'class' => 'sale']
                                        );
                                    }
                                    ?>
                                </div>
                            </div>
                        <?php endforeach; ?>
                    </div>
                    <?= LinkPager::widget(['pagination' => $pages]); /* постраничная навигация */ ?>
                <?php else: ?>
                    <p>По вашему запросу ничего не найдено.</p>
                <?php endif; ?>
            </div>
        </div>
    </div>
</section>

Сейчас URL страниц результатов поиска у нас выглядит так:

http://www.server.com/catalog/search?query=мужская+летняя+одежда
http://www.server.com/catalog/search?query=мужская+летняя+одежда&page=2
http://www.server.com/catalog/search?query=мужская+летняя+одежда&page=3
..........

Добавим правила маршрутизации в файл конфигурации :

$config = [
    /* ... */
    'components' => [
        /* ... */
        'urlManager' => [
            /* ... */
            'rules' => [
                'catalog/category/<id:\d+>/page/<page:\d+>' => 'catalog/category',
                'catalog/category/<id:\d+>' => 'catalog/category',
                'catalog/brand/<id:\d+>/page/<page:\d+>' => 'catalog/brand',
                'catalog/brand/<id:\d+>' => 'catalog/brand',
                'catalog/product/<id:\d+>' => 'catalog/product',
                'catalog/search/page/<page:\d+>' => 'catalog/search',
                'catalog/search' => 'catalog/search',
            ],
        ],
    ],
    /* ... */
];

Теперь URL страниц результатов поиска будут выглядеть так:

http://www.server.com/catalog/search?query=мужская+летняя+одежда
http://www.server.com/catalog/search/page/2?query=мужская+летняя+одежда
http://www.server.com/catalog/search/page/3?query=мужская+летняя+одежда
..........

Сейчас наш код формирует вот такой поисковый запрос:

SELECT * FROM `product` WHERE `name` LIKE '%мужская летняя одежда%'

Чтобы запрос что-то вернул, все три слова должны быть в названии товара. И именно в том порядке, в каком они встречаются в поисковом запросе. Если название товара «Одежда летняя мужская» — то такой товар не попадет в результаты поиска. Давайте это исправим.

<?php
namespace app\models;

use Yii;
use yii\data\Pagination;
use yii\db\ActiveRecord;

class Product extends ActiveRecord {

    /* ... */

    /**
     * Результаты поиска по каталогу товаров
     */
    public function getSearchResult($search, $page) {
        $search = $this->cleanSearchString($search);
        if (empty($search)) {
            return [null, null];
        }

        // пробуем извлечь данные из кеша
        $key = 'search-'.md5($search).'-page-'.$page;
        $data = Yii::$app->cache->get($key);

        if ($data === false) { // данных нет в кеше, получаем их заново
            // разбиваем поисковый запрос на отдельные слова
            $words = explode(' ', $search);
            $query = self::find()->where(['like', 'name', $words[0]]);
            for ($i = 1; $i < count($words); $i++) {
                $query = $query->andWhere(['like', 'name', $words[$i]]);
                // $query = $query->orWhere(['like', 'name', $words[$i]]);
            }
            // постраничная навигация
            $pages = new Pagination([/*...*/]);
            $products = $query
                ->offset($pages->offset)
                ->limit($pages->limit)
                ->asArray()
                ->all();
            // сохраняем полученные данные в кеше
            $data = [$products, $pages];
            Yii::$app->cache->set($key, $data);
        }

        return $data;
    }

}

Мы можем сформировать один из двух вариантов запроса — используя andWhere() или orWhere():

SELECT * FROM `product` WHERE (`name` LIKE '%мужская%') AND (`name` LIKE '%летняя%') AND (`name` LIKE '%одежда%')
SELECT * FROM `product` WHERE (`name` LIKE '%мужская%') OR (`name` LIKE '%летняя%') OR (`name` LIKE '%одежда%')

В первом случае, чтобы товар попал в выборку, нужно, чтобы он содержал в названии все три слова. Во втором случае — хотя бы одно слово. Оба варианта — так себе.

Сложный вариант

Давайте добавим в поисковый запрос расчет релевантности. И искать будем не только в названии товара, но и в описании. А результаты поиска отсортируем по убыванию релевантности. В итоге получим такой SQL-запрос:

SELECT
    *,
    IF (`name` LIKE '%мужская%', 2, 0) +
    IF (`content` LIKE '%мужская%', 1, 0) +
    IF (`name` LIKE '%летняя%', 2, 0) +
    IF (`content` LIKE '%летняя%', 1, 0) +
    IF (`name` LIKE '%одежда%', 2, 0) +
    IF (`content` LIKE '%одежда%', 1, 0)
    AS `relevance`
FROM
    `product`
WHERE
    `name` LIKE '%мужская%' OR
    `content` LIKE '%мужская%' OR 
    `name` LIKE '%летняя%' OR
    `content` LIKE '%летняя%' OR
    `name` LIKE '%одежда%' OR
    `content` LIKE '%одежда%'
ORDER BY
    `relevance` DESC
<?php
namespace app\models;

use Yii;
use yii\data\Pagination;
use yii\db\ActiveRecord;
use yii\db\Query;

class Product extends ActiveRecord {

    /* ... */

    /**
     * Результаты поиска по каталогу товаров
     */
    public function getSearchResult($search, $page) {
        $search = $this->cleanSearchString($search);
        if (empty($search)) {
            return [null, null];
        }

        // пробуем извлечь данные из кеша
        $key = 'search-'.md5($search).'-page-'.$page;
        $data = Yii::$app->cache->get($key);

        if ($data === false) { // данных нет в кеше, получаем их заново
            // разбиваем поисковый запрос на отдельные слова
            $words = explode(' ', $search);
            // рассчитываем релевантность для каждого товара
            $relevance = "IF (`name` LIKE '%" . $words[0] . "%', 2, 0)";
            $relevance .= " + IF (`content` LIKE '%" . $words[0] . "%', 1, 0)";
            for ($i = 1; $i < count($words); $i++) {
                $relevance .= " + IF (`name` LIKE '%" . $words[$i] . "%', 2, 0)";
                $relevance .= " + IF (`content` LIKE '%" . $words[$i] . "%', 1, 0)";
            }
            $query = (new Query())
                ->select(['*', 'relevance' => $relevance])
                ->from('product')
                ->where(['like', 'name', $words[0]])
                ->orWhere(['like', 'content', $words[0]]);
            for ($i = 1; $i < count($words); $i++) {
                $query = $query->orWhere(['like', 'name', $words[$i]]);
                $query = $query->orWhere(['like', 'content', $words[$i]]);
            }
            // сортируем разультаты по убыванию релевантности
            $query = $query->orderBy(['relevance' => SORT_DESC]);
            // постраничная навигация
            $pages = new Pagination([
                'totalCount' => $query->count(),
                'pageSize' => Yii::$app->params['pageSize'],
                'forcePageParam' => false,
                'pageSizeParam' => false
            ]);
            $products = $query
                ->offset($pages->offset)
                ->limit($pages->limit)
                ->all();
            // сохраняем полученные данные в кеше
            $data = [$products, $pages];
            Yii::$app->cache->set($key, $data);
        }

        return $data;
    }

}

И последнее, что хотелось бы сделать — изменить URL страниц результатов поиска, чтобы они имели вид:

http://www.server.com/catalog/search/query/мужская+летняя+одежда
http://www.server.com/catalog/search/query/мужская+летняя+одежда/page/2
http://www.server.com/catalog/search/query/мужская+летняя+одежда/page/3
..........

Для этого изменим метод отправки данных с GET на POST. И добавим в форму скрытое поле со значением CSRF токена, чтобы не получить ошибку

Bad Request (#400): Не удалось проверить переданные данные.
<div class="col-sm-4">
    <form method="post" action="<?= Url::to(['catalog/search']); ?>" class="pull-right">
        <?=
        Html::hiddenInput(
            Yii::$app->request->csrfParam,
            Yii::$app->request->csrfToken
        );
        ?>
        <div class="input-group">
            <input type="text" name="query" class="form-control" placeholder="Поиск по каталогу">
            <div class="input-group-btn">
                <button class="btn btn-default" type="submit">
                    <span class="glyphicon glyphicon-search"></span>
                </button>
            </div>
        </div>
    </form>
</div>

А в контроллере будем делать редирект на красивый URL результатов поиска, если данные пришли методом POST:

<?php
namespace app\controllers;

use app\models\Category;
use app\models\Brand;
use app\models\Product;
use yii\web\HttpException;
use Yii;

class CatalogController extends AppController {

    /* ... */

    /**
     * Результаты поиска по каталогу товаров
     */
    public function actionSearch($query = '', $page = 1) {
        /*
         * Чтобы получить ЧПУ, выполняем редирект на catalog/search/query/одежда
         * после отправки поискового запроса из формы методом POST. Если строка
         * поискового запроса пустая, выполняем редирект на catalog/search.
         */
        if (Yii::$app->request->isPost) {
            $query = Yii::$app->request->post('query');
            if (is_null($query)) {
                return $this->redirect(['catalog/search']);
            }
            $query = trim($query);
            if (empty($query)) {
                return $this->redirect(['catalog/search']);
            }
            $query = urlencode(Yii::$app->request->post('query'));
            return $this->redirect(['catalog/search/query/'.$query]);
        }

        $page = (int)$page;

        // получаем результаты поиска с постраничной навигацией
        list($products, $pages) = (new Product())->getSearchResult($query, $page);

        // устанавливаем мета-теги для страницы
        $this->setMetaTags('Поиск по каталогу');

        return $this->render(
            'search',
            compact('products', 'pages')
        );
    }

}

И изменим правила маршрутизации в файле конфигурации :

$config = [
    /* ... */
    'components' => [
        /* ... */
        'urlManager' => [
            /* ... */
            'rules' => [
                'catalog/category/<id:\d+>/page/<page:\d+>' => 'catalog/category',
                'catalog/category/<id:\d+>' => 'catalog/category',
                'catalog/brand/<id:\d+>/page/<page:\d+>' => 'catalog/brand',
                'catalog/brand/<id:\d+>' => 'catalog/brand',
                'catalog/product/<id:\d+>' => 'catalog/product',
                // правило для 2, 3, 4 страницы результатов поиска
                'catalog/search/query/<query:.*?>/page/<page:\d+>' => 'catalog/search',
                // правило для первой страницы результатов поиска
                'catalog/search/query/<query:.*?>' => 'catalog/search',
                // правило для первой страницы с пустым запросом
                'catalog/search' => 'catalog/search',
            ],
        ],
    ],
    /* ... */
];

Поиск: Web-разработка • Yii2 • Интернет магазин • Каталог товаров • Поиск • Практика • Фреймворк • ЧПУ • Форма • Form • CSRF • LIKE • Запрос • SEF

Каталог оборудования
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Производители
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Функциональные группы
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.