Сквозной поиск для сайта на Yii
При разработке любого более-менее крупного проекта на Yii у программиста может возникнуть необходимость внедрения поиска. И если для интернет-магазина будет достаточно искать только по каталогу, то для информационного сайта нужно обеспечить сквозной поиск по нескольким сущностям сразу. В конце этого урока мы рассмотрим готовые решения по поиску, а в начале для образовательных целей напишем свой велосипед.
Поиск по нескольким таблицам
На сайте из нескольких разделов более вероятно, что материалы описываются разными сущностями и хранятся в разных таблицах. Исключение составляют проекты с CCK, например Drupal с модулем Views или 1C Bitrix с его инфоблоками. Чаще всего в них все придуманные разработчиком в панели управления сущности хранятся в одной общей таблице. Но на Yii такой подход не распространён, поэтому возникает задача склейки выборок из нескольких таблиц. Эту проблему мы и рассмотрим.
В простейшем случае, для поиска по таблице используется SQL запрос с оператором LIKE. По одной таблице мы можем искать вхождение строки в заголовок или текст так:
SELECT title, text FROM tbl_post WHERE title LIKE :query OR text LIKE :query
Пока не будем вдаваться в поиск по словоформам, по вариантам перестановки искомых слов и прочей семантике.
Предположим, что нам нужно производить поиск по блогу, новостям и страницам. Нам на помощь придёт SQL оператор UNION
. Именно он служит для склейки результатов нескольких запросов в одну ленту. Самое банальное решение по выборке из трёх таблиц может выглядеть так:
SELECT title, text FROM tbl_new WHERE title LIKE :query OR text LIKE :query UNION SELECT title, text FROM tbl_post WHERE title LIKE :query OR text LIKE :query UNION SELECT title, text FROM tbl_page WHERE title LIKE :query OR text LIKE :query
Или если вынесем общие элементы за скобки:
SELECT t.* FROM ( SELECT title, text FROM tbl_new UNION SELECT title, text FROM tbl_post UNION SELECT title, text FROM tbl_page ) AS t WHERE t.title LIKE :query OR t.text LIKE :query;
От трёх поисков и склейки мы перешли к одному поиску по одной склеенной временной выборке. На основе этого запроса мы и будем строить варианты решения. Первый вариант – это непосредственное использование этого запроса в DAO.
Если, например, для записей блога используется Markdown синтаксис или какие-либо другие фильтры с преобразованием HTML кода в поле text_purified при сохранении записи, то мы можем использовать псевдонимы для подмены поля text
:
SELECT title, text FROM tbl_new UNION SELECT title, text_purified AS text FROM tbl_post UNION SELECT title, text FROM tbl_page
В любом случае итоговые имена полей в каждом подзапросе должны быть одинаковыми. Теперь приступим к поиску по результирующей выборке.
Поиск с использованием DAO
Если записей на сайте не так уж и много, то можно одним запросом без указания LIMIT
и OFFSET
выбрать все результаты в ассоциированный массив $items
и передать его в CArrayDataProvider
, который позаботится о разбивке на страницы:
class SearchController extends CController { const ITEMS_PER_PAGE = 10; public function actionIndex($query) { $items = Yii::app()->db->createCommand(" SELECT t.* FROM ( SELECT title, text FROM {{new}} UNION SELECT title, text FROM {{post}} UNION SELECT title, text FROM {{page}} ) AS t WHERE t.title LIKE :query OR t.text LIKE :query ")->queryAll(true, array( ':query'=>'%' . $query . '%', )); $dataProvider = new CArrayDataProvider($items, array( 'pagination'=>array( 'pageSize'=>self::ITEMS_PER_PAGE, ) )); $this->render('index', array( 'dataProvider'=>$sqlDataProvider, 'query'=>$query, )); } }
Это не очень оптимальный вариант, так как если найдётся тысяча записей, то в массиве окажутся они все. Для избежания такой растраты памяти можно перейти к использованию CSqlDataProvider
, передав ему непосредственно сам SQL-запрос и число элементов:
class SearchController extends CController { const ITEMS_PER_PAGE = 10; public function actionIndex($query) { $from = "( SELECT CONCAT('new_', id) AS id, title, text FROM {{new}} UNION SELECT CONCAT('post_', id) AS id, title, text FROM {{post}} UNION SELECT CONCAT('page_', id) AS id, title, text FROM {{page}} )"; $where = 'WHERE t.title LIKE :query OR t.text LIKE :query'; $params = array( ':query'=>'%' . $query . '%', ); $countSql = 'SELECT COUNT(*) FROM ' . $from . ' AS t ' . $where; $dataSql = 'SELECT t.* FROM ' . $from . ' AS t ' . $where; $count = Yii::app()->db->createCommand($countSql)->queryScalar($params); $sqlDataProvider = new CSqlDataProvider($dataSql, array( 'params'=>$params, 'keyField'=>'id', 'totalItemCount'=>$count, 'pagination'=>array( 'pageSize'=>self::ITEMS_PER_PAGE, ), )); $this->render('index', array( 'dataProvider'=>$sqlDataProvider, 'query'=>$query, )); } }
Здесь мы для удобства разделили запросы на части $from
и $where
.
Класс CArrayDataProvider
сам произведёт установку параметров LIMIT
и OFFSET
для разбивки на страницы. Этому компоненту нужно указать ключевое поле, но значения id
из разных таблиц будут повторяться. Поэтому мы схитрили и собрали это поле динамически (добавив префиксы):
SELECT CONCAT('new_', id) AS id, ... SELECT CONCAT('post_', id) AS id, ... SELECT CONCAT('page_', id) AS id, ...
Теперь этот CSqlDataProvider
можно использовать привычным способом как и CActiveDataProvider
в представлении views/search/index.php
:
<h1>Поиск по запросу <?php echo CHtml::encode($query); </h1> $this->widget('zii.widgets.CListView', array( 'dataProvider'=>$dataProvider, 'itemView'=>'_item', ));
Но выводить элементы в представлении views/search/_item.php
нужно с той лишь разницей, что у нас каждая запись представляет из себя ассоциативный массив, а не объект:
<h2><?php echo CHtml::encode($data['title']; ?></h2> <p><?php echo mb_substr(strip_tags($data['text']), 200, 'UTF-8'); ?></p>
Здесь мы ограничились выводом в представлении первых 200 символов текста. При желании можно придумать подсветку найденного слова. Например так:
class SearchHighlighter { public static function getFragment($text, $word){ if ($word) { $pos = max(mb_stripos($text, $word, null, 'UTF-8') - 100, 0); $fragment = mb_substr($text, $pos, 200, 'UTF-8'); $highlighted = str_ireplace($word, '<mark>' . $word . '</mark>', $fragment); } else { $highlighted = mb_substr($text, 0, 200, 'UTF-8'); } return $highlighted; } }
<h1>Поиск по запросу <?php echo CHtml::encode($query); </h1> $this->widget( 'zii.widgets.CListView', array( 'dataProvider'=>$dataProvider, 'itemView'=>'_view', 'viewData'=> array('query'=>$query), ));
<h2><?php echo CHtml::encode($data['title']; ?></h2> <p><?php echo SearchHighlighter::getFragment(strip_tags($data['text']), $query); ?></p>
Наш поиск уже должен работать. Осталось рассмотреть ещё пару нюансов.
Добавление ссылки на материал
C выводом заголовка и фрагмента текста в ленте результатов поиска нужно выводить ссылку на источник.
В простейшем случае адреса можно генерировать используя конкатенацию прямо в запросе:
SELECT t.* FROM ( SELECT title, text, CONCAT('/news/', id) AS url FROM {{new}} UNION SELECT title, text, CONCAT('/blog/post/', id) AS url FROM {{post}} UNION SELECT title, text, CONCAT('/page/', alias) AS url FROM {{page}} ) ...
В каждой части запроса можно использовать связи таблиц. Например, если ссылки на посты блога должны включать в себя псевдоним категории, то можно построить JOIN
для таблиц постов и категорий:
SELECT t.* FROM ( SELECT CONCAT('new_', id) AS id, title, text, CONCAT('/news/', id) AS url FROM {{new}} UNION SELECT CONCAT('post_', p.id) AS id, p.title AS title, p.text_purified AS text, CONCAT('/blog/', c.alias, '/' , p.id) AS url FROM {{post}} AS p LEFT JOIN {{category}} AS c ON p.category_id = c.id UNION SELECT CONCAT('page_', id) AS id, title, text, CONCAT('/page/', alias) AS url FROM {{page}} ) ...
Теперь для вывода ссылки можно использовать значение псевдополя $data['url']
:
<h2><?php echo CHtml::link(CHtml::encode($data['title']), $data['url']); </h2>
Этот подход подойдёт практически для всех случаев, за исключением многоуровневых категорий, вложенных страниц и прочих нестандартных реализаций ЧПУ. Этого мы коснёмся вдальнейшем. А сейчас попробуем упростить наши результирующие запросы.
Использование представлений в БД
При работе с СУБД на уроках информатики ученикам даётся задание создать несколько таблиц, а потом на основе содержащихся в них данных сконструировать какие-либо представления. Это на самом деле виртуальные таблицы, которые не содержат своей информации, а выводят записи из других таблиц. Фактически, это именованный и сохранённый в БД отдельный SQL запрос. Этим инструментом мы и можем воспользоваться.
Первым делом, создадим в базе данных наше представление:
CREATE OR REPLACE VIEW tbl_view_search AS SELECT CONCAT('new_', id) AS id, title, text, CONCAT('/news/', id) AS url FROM tbl_new UNION SELECT CONCAT('post_', id) AS id, title, text, CONCAT('/blog/post/', id) AS url FROM tbl_post UNION SELECT CONCAT('page_', id) AS id, title, text, CONCAT('/page/', alias) AS url FROM tbl_page;
Теперь мы можем делать выборку из него как из обычной таблицы:
SELECT * FROM tbl_view_search WHERE title LIKE '%Yii%' OR text LIKE '%Yii%';
Представление можно создать данным запросом вручную, либо в миграции или (для экспериментов) непосредственно в контроллере перед запросом:
Yii::app()->db->createCommand(" CREATE OR REPLACE VIEW {{view_search}} AS SELECT CONCAT('new_', id) AS id, title, text, CONCAT('/news/', id) AS url FROM {{new}} UNION SELECT CONCAT('post_', id) AS id, title, text, CONCAT('/blog/post/', id) AS url FROM {{post}} UNION SELECT CONCAT('page_', id) AS id, title, text, CONCAT('/page/', alias) AS url FROM {{page}} ")->execute();
Теперь будем использовать это представление как таблицу для наших выборок:
$where = 't.title LIKE :query OR t.text LIKE :query'; $countSql = 'SELECT COUNT(*) FROM {{view_search}} WHERE ' . $where; $dataSql = 'SELECT * FROM {{view_search}} WHERE ' . $where;
Достоинство этого подхода (с выносом подзапроса в именованное представление) в том, что можно настраивать поиск для каждого сайта прямо в схеме базы, не влезая в программный код приложения.
Если Вы не любите использовать DAO или если ссылки в вашем проекте генерируются не очень банально (например, если проект многоязычный и нужно использовать указание языка в адресе), то можно воспользоваться достоинствами ActiveRecord.
Использование ActiveRecord
Замечательной особенностью представлений в БД является то, что они воспринимаются внешним миром как таблицы. Соответственно, для работы с этой виртуальной таблицей как с реальной мы можем использовать модель CActiveRecord
.
Например, если у нас есть представление:
CREATE OR REPLACE VIEW tbl_view_search AS SELECT title, text, CONCAT('/news/', id) AS url FROM {{new}} UNION SELECT title, text, CONCAT('/blog/post/', id) AS url FROM {{post}} UNION SELECT title, text, CONCAT('/page/', alias) AS url FROM {{page}}
мы можем создать для него модель:
/** * @property string $title * @property string $text * @property string $url */ class Search extends CActiveRecord { public static function model($className=__CLASS__) { return parent::model($className); } public function tableName() { return '{{view_search}}'; } }
Теперь в коде контроллера мы можем работать с этой моделью как с любой другой:
class SearchController extends Controller { public function actionIndex($query) { $criteria = new CDbCriteria(); $criteria->addSearchCondition('title', $query); $criteria->addSearchCondition('text', $query, true, 'OR'); $dataProvider = new CActiveDataProvider('Search', array( 'criteria'=>$criteria, )); $this->render('search', array( 'dataProvider'=>$dataProvider, 'query'=>$query, )); } }
и также привычно выводить результаты поиска:
<h2><?php echo CHtml::link(CHtml::encode($data->title), $data->url); </h2>
Здесь, как и прежде, адреса собираются конкатенацией в самом запросе.
Построение нестандартных ссылок
Иногда конкатенации и подключения частей через JOIN запрос может не хватать. Например, при генерации ссылок на вложенные страницы.
Хорошей практикой является добавление метода getUrl()
в модель. Это позволяет просто использовать везде где нужно $model->url
вместо громоздкой записи Yii::app()->createUrl(...)
с различными для каждой сущности параметрами.
Добавим этот метод в наши модели:
class News extends CAtiveRecord { ... private $_url; public function getUrl(){ if ($this->_url === null) $this->_url = Yii::app()->createUrl('news/view', array('id'=>$this->id, 'alias'=>$this->alias)); return $this->_url; } } class Post extends CAtiveRecord { ... private $_url; public function getUrl(){ if ($this->_url === null) $this->_url = Yii::app()->createUrl('blog/view', array('id'=>$this->id, 'alias'=>$this->alias)); return $this->_url; } } class Page extends CAtiveRecord { ... private $_url; public function getUrl() { if ($this->_url === null) { $this->_url = Yii::app()->createUrl('page/view', array('path'=>$this->getPath())); } return $this->_url; } public function getPath() { ... } }
Модель страницы отличается от моделей записи блога и новости тем, что содержит метод getPath, который склеивает вложенный псевдоним (например about/company/personal
). А потом уже модель строит адрес на основе этого псевдонима.
Теперь для генерации ссылок для наших результатов поиска нужно использовать метод getUrl()
соответствующей модели.
Изменим наше представление следующим образом:
CREATE OR REPLACE VIEW tbl_view_search AS SELECT title, text, id AS material_id, 'News' AS material_class FROM tbl_new UNION SELECT title, text, id AS material_id, 'Post' AS material_class FROM tbl_post UNION SELECT title, text, id AS material_id, 'Page' AS material_class FROM tbl_page;
Представление будет возвращать идентификатор material_id
и класс material_class
, по которым можно будет найти оригинал модели. Если необходимо ипользовать Yii::import
, то путь для него тоже можно возвращать из выборки:
CREATE OR REPLACE VIEW tbl_view_search AS SELECT title, text, id AS material_id, 'news.models.News' AS material_import, 'News' AS material_class FROM tbl_new UNION SELECT title, text_purified AS text, id AS material_id, 'blog.models.Post' AS material_import, 'Post' AS material_class FROM tbl_post UNION SELECT title, text, id AS material_id, '' AS material_import, 'Page' AS material_class FROM tbl_page UNION SELECT title, '' AS text, id AS material_id, 'photo.models.Photo' AS material_import, 'Photo' AS material_class FROM tbl_photo;
Здесь мы также добавили поиск по фотографиям. У них есть только заголовок и нет текста, поэтому в поле text
мы возвращаем пустую строку. Аналогично можно легко добавить в поиск любую сущность.
Теперь необходимо доработать нашу модель поиска так, чтобы по свойству $model->material
можно было бы получить доступ к оригинальной модели практически как через ленивую загрузку через отношение BELONGS_TO
:
/** * @property string $title * @property string $text * @property string $material_import * @property string $material_class * @property integer $material_id */ class Search extends CActiveRecord { public static function model($className=__CLASS__) { return parent::model($className); } public function tableName() { return '{{view_search}}'; } private $_material; public function getMaterial() { if ($this->_material === null){ if ($this->material_import){ Yii::import($this->material_import); } $this->_material = CActiveRecord::model($this->material_class)->findByPk($this->material_id); } return $this->_material; } }
Контроллер мы оставим без изменений:
class SearchController extends Controller { public function actionIndex($query) { $criteria = new CDbCriteria(); $criteria->addSearchCondition('title', $query); $criteria->addSearchCondition('text', $query, true, 'OR'); $dataProvider = new CActiveDataProvider('Search', array( 'criteria'=>$criteria, )); $this->render('search', array( 'dataProvider'=>$dataProvider, 'query'=>$query, )); } }
При выводе списка результатов мы теперь можем вызывать метод getUrl()
соответствующей модели, к экземпляру которой мы можем обращаться через отношение getMaterial()
нашей модели Search
:
<h2><?php echo CHtml::link(CHtml::encode($data->title), $data->material->url); </h2>
Теперь мы можем легко добавить в поиск любого сайта на Yii новые сущности, заменив тело представления в базе данных и не изменив ни одной строки исходного кода контроллера или моделей. Это очень хороший результат.
Непосредственное получение экземпляров моделей
В последнем примере оригинальную запись можно получить через отношение $data->material
. Но существует возможность возвращать оригинальные модели сразу в выборке, то есть оригинал будет содержаться вместо экземпляра класса Search
прямо в переменной $data
.
Этого можно добиться переопределив метод создания экземпляра модели CActiveRecord::instantiate
:
/** * @property string $title * @property string $text * @property string $material_import * @property string $material_class * @property integer $material_id */ class Search extends CActiveRecord { public static function model($className=__CLASS__) { return parent::model($className); } public function tableName() { return '{{view_search}}'; } protected function instantiate($attributes) { if ($this->material_import){ Yii::import($this->material_import); } return CActiveRecord::model($this->material_class)->findByPk($this->material_id); } }
Данный метод будет вызываться при создании элементов в поисковых выборках find()
, findByAttributes()
, findAll()
и findAllByAttributes()
. Вместо экземпляра класса Search
мы возвращаем экземпляр оригинального класса.
Определяя оригинальный класс элемента с помощью instanceof
в _view.php
мы можем подключать соответствующее типу материала представление:
<?php if ($data instanceof Page)) { $view = 'application.modules.page.views.default._view'; } elseif ($data instanceof News)) { $view = 'application.modules.new.views.default._view'; } elseif ($data instanceof BlogPost)) { $view = 'application.modules.blog.views.default._view'; } else { $view = '_default_view'; } $this->renderPartial($view, array( 'data'=>$data, 'index'=>$index, 'widget'=>$widget, ));
Теперь все найденные материалы будут выведены в одной ленте, причем каждый будет выведен в своём персональном оформлении. Для подключения новых сущностей достаточно лишь изменить SQL запрос в БД и привязать сооответствующие шаблоны.
Подобный трюк с использованием представления в базе данных и с переопределённым методом
CActiveRecord::instantiate
, привязанной к данному представлению модели, можно использовать не только для поиска, но и, например, для агрегации материалов различных типов в одну ленту RSS с общей сортировкой по дате.
Ну и как было обещано, пробежимся по некоторым альтэрнативам.
Другие решения по организации поиска на сайте
Здесь мы затронули простейший вариант поиска с использованием оператора LIKE
. Кроме него в MySQL можно использовать и другие операторы, но это требует использования движка MyISAM и обязательное построение индексов.
Более сложные варианты предполагают использование сторонних систем. Их отличает то, что они обеспечивают полнотекстовый поиск, то есть способны не обращать внимания на порядок слов в запросе и словоформы. С примерами их использования в Yii можно ознакомиться, например, здесь, здесь и здесь. У каждого из этих решений свои особенности работы и свой порядок интеграции в проект.
Великолепная статья, а главное очень вовремя опубликованная, у меня как раз стоит задача в одном из проектов реализация простого поиска по разным сущностям.
Искать средствами одного мускуля с помощью LIKE не целесообразно.
Спасибо интересно было читать, очень жду статью о событиях. Потому как только после вашей статьи про поведения, я понял, что это такое и начал использовать их в своём коде. Надеюсь будет статья о событиях.
Может вскоре напишу. Хотя их прямо почти не использую. Только косвенно.
Напишите если у вас будет время, было бы интересно прочитать. После прочтения "рецепта" на сайте самого Yii для меня вопрос остался открытым (не так как я себе это представлял), к сожалению. Можно ли в принципе использовать их не "раздувая" модели, а вешать их в контроллере.
Сделал небольшой экскурс в события.
Было бы неплохо отметить немного в статье почему именно UNION, по опыту могу сказать что он помогать будет до тех пор пока записей будет "не много". Я решал задачу иначе, написав поведение для сохранения/удаление модели, которое сохраняет/удаляет запись в отдельной таблице хранения ключевых слов, где происходит связка по названию модели и её первичному ключу
Для больших объёмов уже LIKE или HAVING итак не подойдут, так что UNION сделает всё как наиболее простой способ без лишних телодвижений. А ваш подход с отдельной таблицей для поиска уже сродни ведению индексов в упомянутых Zend Lucene и Sphinx, то есть более серьёзный.
Годно до 10к страниц вполне нормально.
Хороший сайт и хороший материал! Было бы не плохо если бы описали способ работы с zend_search_lucene или настройка работы с помощью sphinx
А если я хочу дополнительно искать по полю fulltext например?
Вот фрагмент кода:
https://gist.github.com/dignityinside/c33142797ce4b63cacc0
Заранее спасибо.
Можно попробовать:
Наверно я что-то намудрил:
https://gist.github.com/dignityinside/c33142797ce4b63cacc0#comment-975673
Добрый день! Очень понравилась статья. Расскажите пожалуйста как можно сделать фильтрацию по gridview с csqldataprovider. Не могу решить проблему уже третий день. Заранее спасибо!
В крайнем случае можно поступить аналогичным образом: сохранить этот запрос в представление базы данных и обернуть ActiveRecord моделью. Тогда можно будет использовать CActiveDataProvider как обычно.
Не понимаю как составить запрос. Хочу искать в блоге и в комментах
в блоге title, link, short
в комментах text
делаю так:
CDbCommand не удалось исполнить SQL-запрос: SQLSTATE[21000]: Cardinality violation: 1222 The used SELECT statements have a different number of columns. The SQL statement executed was: SELECT COUNT(*) FROM (
Все разобрался, спасибо за статью!!!
Есть ли исходный код?
Я начинающий, мне трудно понять как использовать код, напр. class SearchHighlighter (где создать файл и как использовать?).
Создаёте в папке components, например.
На yii1.1.16 метод instantiate у меня не сработал... $this - пустая.
получилось так
Bolshooooooooi rahmet, bratan!
Получается представление предается стирать и записывать заново при каждом добавлении или обновлении Постов страниц и новостей?
Представление - это всего лишь SQL-запрос. Данные будет всегда возвращать свежие. Перезаписывать нужно будет только для изменения самого запроса.
то есть его достаточно вставить в экшен search и каждый раз он будет обновляться ?
К чему вопрос когда я второй раз использую поисковой запрос вот такого плана
выдает такую ошибку
Используйте CREATE OR REPLACE VIEW.
Ув. Дмитрий,
А как адаптировать вашу статью под Yii2? А именно класс Search - метод ActiveRecord::model() не существует.
Убрать этот метод. Использовать ::find().
Дмитрий, но метод ::find() применителен к определённой модели ActiveRecord, а вы здесь используете ::model() для того чтобы разложить их по полочкам.Как будет выглядеть аналог такого подхода для yii2? Может это слишком глупый вопрос, но я новичок в этом деле, и не могу понять как это реализовать.
Вместо:
будет:
Дмитрий, спасибо большое. Стыдно, что сам до этого не додумался. Уже реализовал поиск, но немного иным способом. Спасибо за отзывчивость.
Использую yii2, и при обращение к модели получаю такую ошибку Using $this when not in object context.
Вот код модели поиска