Вывод иерархических пунктов в CGridView в Yii
Недавно в обратную связь поступил вопрос. Один из читателей поинтересовался, как можно сделать удобный вывод иерархических данных, построенных по принципу Adjacency List, в виджете CGridView
вместо CTreeView
. Это, например, могут быть вложенные статические страницы, категории или пункты меню, хранимые в базе данных. Попробуем решить этот вопрос.
При работе с Nested Set не возникает проблем, так как всё дерево пунктов можно получить одним SQL-запросом и использовать значение поля level
модели для отрисовки сдвига. Другое дело – принцип Adjacency list, где отношение к родительскому пункту указывается только с помощью поля parent_id
. Для вывода такой иерархии требуется использование рекурсивного обхода. Попробуем его и реализовать.
Вначале «препарируем» виджет CGridView
и изучим его работу.
Итак, данный компонент занимается выводом таблицы элементов. Он генерирует «шапку» таблицы, её «тело» и «подвал»:
abstract class CBaseListView extends CWidget { public $dataProvider; ... } class CGridView extends CBaseListView { public function renderItems() { if($this->dataProvider->getItemCount()>0 || $this->showTableOnEmpty) { echo "<table class=\"{$this->itemsCssClass}\">\n"; $this->renderTableHeader(); ob_start(); $this->renderTableBody(); $body=ob_get_clean(); $this->renderTableFooter(); echo $body; // TFOOT must appear before TBODY according to the standard. echo "</table>"; } else $this->renderEmptyText(); } public function renderTableBody() { $data=$this->dataProvider->getData(); $n=count($data); echo "<tbody>\n"; if($n>0) { for($row=0;$row<$n;++$row) $this->renderTableRow($row); } else { echo '<tr><td colspan="'.count($this->columns).'" class="empty">'; $this->renderEmptyText(); echo "</td></tr>\n"; } echo "</tbody>\n"; } public function renderTableRow($row) { ... $data=$this->dataProvider->data[$row]; ... echo CHtml::openTag('tr', $htmlOptions)."\n"; foreach($this->columns as $column) $column->renderDataCell($row); echo "</tr>\n"; } ... }
Сейчас нас интересует только генерация строк с данными.
Как мы видим, всё, что для этого делает CGridView
– это получение элементов из поставщика данных, содержащегося в поле dataProvider
, методом getData()
с последующим выводом строк таблицы в цикле по полученным элементам.
Таким образом, виджет CGridView
занимается только построением таблицы. Ему совершенно безразлично, что к нему приходит из метода $dataProvider->getData()
. За этой инфраструктурой в самом объекте провайдера скрывается внутренний метод fetchData()
:
abstract class CDataProvider extends CComponent implements IDataProvider { public function getData($refresh=false) { if($this->_data===null || $refresh) $this->_data=$this->fetchData(); return $this->_data; } public function getKeys($refresh=false) { if($this->_keys===null || $refresh) $this->_keys=$this->fetchKeys(); return $this->_keys; } public function getTotalItemCount($refresh=false) { if($this->_totalItemCount===null || $refresh) $this->_totalItemCount=$this->calculateTotalItemCount(); return $this->_totalItemCount; } abstract protected function fetchData(); abstract protected function fetchKeys(); abstract protected function calculateTotalItemCount(); }
Все провайдеры в Yii наследуются от класса CDataProvider
и по-своему реализуют методы fetchData
, fetchKeys
и calculateTotalItemCount
.
Такое полиморфное поведение позволяет без проблем использовать абсолютно любой провайдер данных. Каждый из них сортирует, разбивает на страницы (используя встроенные компоненты CSort
и CPagination
) и возвращает массивы элементов. Разница лишь в том, откуда каждый получает исходные данные.
CActiveDataProvider
получает массив моделей, вызываяCActiveRecord::findAll
,CArrayDataProvider
аналогично возвращает фрагмент из исходного массива,CSqlDataProvider
непосредственно выполняет SQL запросы к базе данных.
Аналогично мы можем создать любой наследник CDataProvider
для обеспечения аналогичной работы с любыми хранилищами. Например, для вывода строк в CGridView
путём парсинга в массив каких либо Log-файлов (protected/applocation.log
или логов вашего сервера). Просто пишем класс LogDataProvider
, который бы парсил файл в массив. Полный код приводить не будем, так как его можно сделать каким угодно. Основа может быть такой:
class LogDataProvider extends CDataProvider { private $_file = ''; private $_format = ''; private $_data= array(); public function __construct($file, $format) { $this->_file = $file; $this->_format = $format; } protected function fetchData() { ... } protected function fetchKeys() { ... } protected function calculateTotalItemCount() { ... } }
Теперь созаём провайдер в контроллере:
public function actionAdmin() { $file = Yii::app()-params['log_file']; $format = Yii::app()-params['log_format']; $dataProvider = new LogDataProvider($file, $format); $this->render('admin', array( 'dataProvider'=>$dataProvider, )); }
и передаём его в CGridView
:
<?php $this->widget('zii.widgets.grid.CGridView', array( 'dataProvider'=>$dataProvider, 'columns'=>array( 'date', 'type', 'message', ... ), ));
В опции log_format
можно передавать регулярное выражение с именованными параметрами, по которому будут разбиваться строки.
Из этого следует, что виджет
CGridView
пассивно отображает полученные им данные. Никакой конкретной логики выборки данных он не содержит. Выборка полностью производится из провайдера, который, в свою очередь, использует состояние встроенных в него элементовpagination
иsort
для расчёта требуемого числа и порядка сортировки возвращаемых им элементов.
Соответственно, вместо доработки CGridView
для решения нашей задачи нужно переключить внимание на провайдер данных. Так как привычнее работать с ActiveRecord, то рассмотрим CActiveDataProvider
. Как мы помним, за получение массива элементов отвечает метод fetchData
. Пусть он и возвращает элементы, отсортированые по иерархии.
Напишем наследника класса CActiveDataProvider
и переопределим в нём данный метод:
/** * @author ElisDN <mail@elisdn.ru> * @link https://elisdn.ru */ class DTreeActiveDataProvider extends CActiveDataProvider { public $childRelation = 'child_items'; /** * Fetches the data from the persistent data storage. * @return array list of data items */ protected function fetchData() { $criteria=clone $this->getCriteria(); if(($pagination=$this->getPagination())!==false) { $pagination->setItemCount($this->getTotalItemCount()); $pagination->applyLimit($criteria); } $baseCriteria=$this->model->getDbCriteria(false); if(($sort=$this->getSort())!==false) { // set model criteria so that CSort can use its table alias setting if($baseCriteria!==null) { $c=clone $baseCriteria; $c->mergeWith($criteria); $this->model->setDbCriteria($c); } else $this->model->setDbCriteria($criteria); $sort->applyOrder($criteria); } $this->model->setDbCriteria($baseCriteria!==null ? clone $baseCriteria : null); $rootCriteria=clone $criteria; $isEmptyCondition=empty($rootCriteria->condition); if ($isEmptyCondition) $rootCriteria->addCondition('t.parent_id iS NULL OR t.parent_id = 0'); $items=$this->model->findAll($rootCriteria); if ($isEmptyCondition) $items=$this->buildRecursive($items); $this->model->setDbCriteria($baseCriteria); // restore original criteria return $items; } protected function buildRecursive($items, $indent=0, $foolproof=20) { $data=array(); foreach ($items as $item) { $item->indent=$indent; $data[]=$item; if ($foolproof && $item->{$this->childRelation}) $data=array_merge($data, $this->buildRecursive($item->{$this->childRelation}, $indent+1, $foolproof-1)); } return $data; } }
В большинстве своём мы оставили код исходного метода fetchData
как есть, но добавили новый параметр childRelation
и небольшой фрагмент кода в конце. Изначально данный фрагмент был таким:
$rootCriteria=clone $criteria; $rootCriteria->addCondition('t.parent_id = 0'); $items=$this->model->findAll($rootCriteria); $data=$this->buildRecursive($items);
Вместо выборки из всех записей в таблице базы данных мы выбираем только категории верхнего уровня и рекурсивно достраиваем их подкатегории.
Но в такой реализации есть несколько проблем. Представим, что в фильтре поиска указали значение parent_id
. Тогда в методе search()
модели сработает строка:
$criteria->compare('t.parent_id', $this->parent_id);
что добавит в SQL запрос условие, например:
... WHERE t.parent_id = 5
И тогда после наложения ещё и нашего условия:
$rootCriteria->addCondition('t.parent_id = 0');
запрос получится противоречивым:
... WHERE t.parent_id = 5 AND t.parent_id = 0
и таблица окажется пустой.
Конечно же, мы могли проверять на вхождение строки parent_id
выражение для condition
текущего экземпляра $criteria
, но это довольно сложно.
Или в фильтре производится любой поиск. Например, по имени title
. С постоянно подключенным условием parent_id = 0
поиск вообще не будет производиться по дочерним элементам. А если всё-же найдётся несколько родительских элементов, то метод buildRecursive
достроит к каждому из них дерево вложенных элементов. Результат поиска будет искажён.
Вторая проблема может возникнуть, когда поле parent_id
может содержать NULL
. Конечно же, можно в таблице установить ему NOT NULL
, но это решение не универсально, так как ошибка может появиться в другом проекте.
Путём нехитрых опытов и упрощений достаточно реализовать этот фрагмент так, как он и приведён:
$rootCriteria=clone $criteria; $isEmptyCondition=empty($rootCriteria->condition); if ($isEmptyCondition) $rootCriteria->addCondition('t.parent_id iS NULL OR t.parent_id = 0'); $items=$this->model->findAll($rootCriteria); if ($isEmptyCondition) $items=$this->buildRecursive($items); $this->model->setDbCriteria($baseCriteria); // restore original criteria return $items;
Здесь иерархический вывод включается только при остутствии каких-либо условий. При поиске вывод будет производиться обычным списком. В общем, решение неочевидное, но работает.
Ещё один нюанс. В переменную $items
выберется массив найденных результатов, потом этот список передаётся в метод buildRecursive()
, который для каждого элемента рекурсивно добавляет в массив $items
все вложенные объекты. При этом массив так и остаётся одноуровневым. Но нам нужно будет знать значение отступа $indent
для подстановки пробелов перед именем категории в ячейку таблицы. Прямой, но не очень красивый способ – создать поле для хранения уровня внутри модели:
class Category extends CActiveRecord { public $indent = 0; ... }
и при добавлении элемента в массив результатов записывать значение сдвига в это поле:
foreach ($items as $item) { $item->indent=$indent; $data[]=$item; ... }
Если не нравится название $indent
, то его можно изменить на $level
. Это дело вкуса.
И, да, там ещё зачем-то используется переменная $foolproof
, которая с каждым уровнем уменьшается до нуля. Это всего-напросто «защита от дурака». Представим, что кто-то случайно (или специально) перепутал похожие по имени категории и в качестве родительской выбрал дочернюю. Или просто «зациклил» пункт вложенного меню сам на себя. Тогда эту ошибку будет сложно исправить в панели администрирования, так как рекурсия будет продолжаться теоретически бесконечно. То есть страница «зависнет» и выдаст ошибку, превысив системное ограничение на глубину вложенности или истощив все лимиты оперативной памяти. Поэтому параметром $foolproof
мы избежали бесконечной рекурсии, ограничив глубину вложенности до двадцати уровней.
Теперь попробуем подключить наш усовершенствованный провайдер данных. Добавим отношение HAS_MANY
для получения дочерних элементов и доработаем метод search
модели:
class Category extends CActiveRecord { public $indent = 0; public function relations() { return array( 'parent' => array(self::BELONGS_TO, 'Category', 'parent_id'), 'children' => array(self::HAS_MANY, 'Category', 'parent_id', 'order'=>'children.id ASC' ), ); } public function search() { $criteria=new CDbCriteria; $criteria->compare('t.id', $this->id); $criteria->compare('t.title', $this->title, true); $criteria->compare('t.parent_id', $this->parent_id); return new DTreeActiveDataProvider($this, array( 'criteria'=>$criteria, 'childRelation'=>'children', )); } }
Теперь этот провайдер можно использовать в качестве источника данных для CGridView
. Мы помним, что добавили в модель поле indent
. Его значение и будем использовать для простановки пробелов. По четыре пробела на шаг:
<?php $this->widget('zii.widgets.grid.CGridView', array( 'id'=>'posts-grid', 'dataProvider'=>$model->search(), 'filter'=>$model, 'columns'=>array( array( 'name'=>'title', 'value'=>'str_repeat(" ", $data->indent * 4) . CHtml::encode($data->title)', 'type'=>'raw', ), array( 'class'=>'CButtonColumn', ), ), ));
В панели администрирования мы увидим вывод по десять (по умолчанию) родительских элементов на страницу с полностью выведенным деревом вложенных подпунктов. При попытке найти любой пункт по имени вывод дерева автоматически отключится, и результаты поиска выведётся простым списком.
В используемой рекурсивной функции
buildRecursive
используется «ленивая» загрузка подпунктов через отношениеchildren
, что для каждого пункта выполняет запрос к базе данных. Если у вас сотня пунктов, то, соответственно, будут выполняться сто запросов и страница будет без кэширования выводиться несколько секунд. Для десяти пунктов это вполне оптимальный вариант. Альтернативный способ – единственная выборка всех пунктов вызовомfindAll()
в массив и уже рекурсивный обход этого массива. А более серьёзный – использование Nested Set.
Вместо повсеместного повторения конструкции str_repeat
мы можем, например, создать ячейку IndentColumn с этим кодом и использовтаь её в любой таблице.
На сегодня с этим всё. Буду рад комментариям о данном решении.
А демку? Расписано хорошо, но думаешь сначала о nestedsets...
Было бы неплохо добавить в статью скриншотов, что в итоге получилось. А то и не понятно, что же вы наделали, а садиться и писать код, что бы увидеть - сами понимаете, не самое лучшее решение.
Абсолютно поддерживаю
темы статей очень актуальные.... респект!
но отсутствие скриншотов - пичаль!
При такой реализации будет неверно работать пагинатор - на странице будет выводиться по 10 родительских элементов, а число страниц в пагинаторе будет расчитываться от общего кол-ва элементов.
Вполне вероятно. Попробую исправить.
Есть идеи как решить проблему с пагинатором?
Можно добавить аналогичное доплнение
внутрь метода calculateTotalItemCount.
Так подходит только для подсчета общего числа элементов, а при выводе самого пагинатора, количество страниц остается то же
Здравствуйте, Дмитрий.
Дошел по вашей статье до этого момента:
Не подскажете - как исправить неверное отображение страниц в pager-e.
У меня с учетом вложенностей всего две страницы, а в пагинаторе показывается 4.
Добавлял $сriteria->addCondition('t.parent_id iS NULL OR t.parent_id = 0'); - но тогда пропадала вложенность
так будут скриншоты то ?
На локальном сервере работает все норм, На хостинге выводит вот такую простыню. до этого такая же проблема была с расширением silcom-tree-grid-view
На локалке работало, а на хостинге нет.
Помогите новичку разобраться в чем может быть проблема?
Сам понять не смог пока..
CActiveDataProvider выводит нормально
Вероятно, что Категория 0 ссылается в parent на саму себя и её зацикливает.
Нет вот все категории http://joxi.ru/Vrw39WWIjpO5rX
Я же пишу, что на локалке все ок
Подскажите, я читал о баге в некоторых версиях php вроде bugs.php.net/bug.php?id=54547
Может ли это повлиять?
Блин, сорри. Таки да, на хостинге добавлял еще одну категорию.
Спасибо вам огромное. Очень крутой блог! Изучаю yii второй месяц. Во многом разобрался, благодаря ваг
шим публикациям.
Спасибо за то, что вы делаете!
Доброго времени суток !
А не опишите как сделать тоже самое только на yii2 ?
Провайдеры там и там похожи, так что в Yii2 можно практически то же самое в ActiveDataProvider добавить.
Интересная статья, но скрины бы не помешали.
Если есть две таблицы - статьи и лайки.И нужно вывести в GridView данные о статьях и добавить туда поле countLikes с количеством лайков статьи, которые нужно посчитать. Т.е. такого поля в таблице не существует. Нужно посчитать кол-во лайков к статье по связи idPage в табл. лайков к id в pages.
Вероятнее всего манипуляции с подсчетом лайков нужно проводить в модели PagesSearch c dataProvider. т.к. нужно, чьлбы поля были с сортировкой от большего числа к меньшему.
Как можно добавить поле countLikes в GridView c возможностью сортировки по возростанию и убыванию?
Для Yii2 в скринкасте показывал.
А можете ссылку или где искать скринкаст?
Кликните по слову "скринкаст".
) понял