Использование PostgreSQL tsearch2 в проекте на Yii
Любой сайт — это прежде всего тексты. Для того, чтобы тексты было удобно редактировать их часто хранят в БД. При этом появляются дополнительные возможности, такие как удобный поиск по содержимому текстового поля. Старый добрый LIKE хорош, но не всегда. Есть более продвинутые вещи, такие как tsearch2 в PostgreSQL. Как им воспользоваться в Yii Framework я расскажу под катом.
Преамбула
Однажды мне пришлось реализовывать полнотекстовый поиск на одном из сайтов, созданных мной для меня же. Для работы с tsearch2 не нужно долго гуглить и думать как оно работает, потому как на сайте есть исчерпывающее руководство. В чем прелесть использования tsearch2 по сравнению с LIKE думаю тоже объяснять не нужно. На этом сайте информации полно. Так вот, если в приложении писать SQL запросы самому в явном виде то проблем никаких, но я пользуюсь Yii и мне хотелось чтобы все было сделано путем, рекомендуемым разработчиками. Однако это не единственная причина. Для использования виджета CListView нам нужно подготовить экземпляр класса CActiveDataProvider. Вот тут то и началось самое интересное.
Решение
Для использования CActiveDataProvider нам нужно инициализировать объект класса CDbCriteria, однако в нем нет возможности изменить поле FROM для добавления туда вызова функции, формирующего запрос к движку tsearch2, вида:
SELECT ... FROM ..., to_tsquery($sh_string) AS q ...
Остается только использование модели, заполняемой вызовом методом findAllBySql, однако встроенный в CActiveDataProvider механизм поддерживает только findAll. Так как же нам сконвертировать модель, полученную из чистого SQL запроса, в CActiveDataProvider? Решение было найдено на Stackoverflow однако на этом неприятности не закончились. У меня, в таблице где хранились тексты, ключевое поле называлось не id, а txt_id, в связи с этим пришлось вносить в текст из ответа небольшую, но очень важную поправку. Вот что получилось у меня.
В контроллере:
$_shString = '';
if (isset($_GET['sh']))
{
$_shString = implode('&', explode(' ', $_GET['sh']));
$_qry = "
SELECT
txt_id,
ts_headline(txt, q, 'StartSel=<strong>, StopSel=</strong>, MaxWords=35, MinWords=15') AS txt,
ts_rank(fti_txt, q) AS rank
FROM
texts, to_tsquery(:sh) AS q
WHERE
user_id=:uid AND fti_txt @@ q
ORDER BY rank DESC
";
$_model = Texts::model()->findAllBySql($_qry, array(':uid' => Yii::app()->user->id, ':sh' => $_shString));
}
В представлении:
$this->widget('zii.widgets.CListView', array(
'id' => 'search-results',
'dataProvider' => new CArrayDataProvider($model, array('keyField' => 'txt_id')),
'itemView' => '_text',
));
Та самая правка относится к
array('keyField' => 'txt_id')
UPD. 2012-04-02
Как написал в каментах уважаемый Rive, проблема в том, что при таком подходе все результаты поиска будут выбираться как изначально, так и при переходе по страницам в CListView. В поисках решения мне пришлось задать вопрос на форуме фреймворка на что был незамедлительно получен короткий, но полностью исчерпывающий ответ! Кому лень читать скажу, что суть ответа: Используйте CSqlDataProvider
Мой код получился таким. В контроллере:
$_shString = '';
if (isset($_GET['sh']))
{
$_shString = implode('&', explode(' ', $_GET['sh']));
$_cnt = Yii::app()->db->createCommand('SELECT count(*) FROM texts, to_tsquery(:sh) AS q WHERE user_id=:uid AND fti_txt @@ q')->queryScalar(array(':uid' => Yii::app()->user->id, ':sh' => $_shString));
$_qry = "
SELECT
txt_id,
ts_headline(txt, q, 'StartSel=<strong>, StopSel=</strong>, MaxWords=35, MinWords=15') AS txt,
ts_rank(fti_txt, q) AS rank
FROM
texts, to_tsquery(:sh) AS q
WHERE
user_id=:uid AND fti_txt @@ q
ORDER BY rank DESC
";
$dataProvider = new CSqlDataProvider($_qry, array(
'totalItemCount' => $_cnt,
'params' => array(':uid' => Yii::app()->user->id, ':sh' => $_shString),
'keyField' => 'txt_id',
'pagination' => array(
'pageSize' => 20,
),
));
}
Далее во view:
$this->widget('zii.widgets.CListView', array(
'id' => 'search-results',
'dataProvider' => $dataProvider,
'itemView' => '_text',
));
Как видим, для прорисовки каждого элемента списка используется вспомогательный view _text.php, вот простейший вариант его содержимого:
<div class="view">
<?php
//echo CHtml::encode($data->txt);
//echo $data->txt;
echo $data['txt'];
?>
</div>
Особо нужно обратить внимание на два момента:
- При создании экземпляра CSqlDataProvider надо отдельно находить и передавать ему общее количество записей, возвращаемое запросом. Получается, что первоначально выполняется два аналогичных по тяжести запроса. Это прописано в официальной документации
- Получаемый при «проходе» по результатам запроса компонент $data внутри провайдера имеет формат массива, а не объекта. Исходник _text.php я привел не зря, тут надо использовать echo $data['txt']; вместо echo $data->txt;
Надеюсь этот опыт будет кому-нибудь полезным!