Вывод иерархических пунктов в 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
<?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
<?php $this->widget('zii.widgets.grid.CGridView', array(
    'id'=>'posts-grid',
    'dataProvider'=>$model->search(),
    'filter'=>$model,
    'columns'=>array(
        array(
            'name'=>'title',
            'value'=>'str_repeat("&nbsp;", $data->indent * 4) . CHtml::encode($data->title)',
            'type'=>'raw',
        ),
        array(
            'class'=>'CButtonColumn',
        ),
    ),
)); ?>

В панели администрирования мы увидим вывод по десять (по умолчанию) родительских элементов на страницу с полностью выведенным деревом вложенных подпунктов. При попытке найти любой пункт по имени вывод дерева автоматически отключится, и результаты поиска выведётся простым списком.

В используемой рекурсивной функции buildRecursive используется «ленивая» загрузка подпунктов через отношение children, что для каждого пункта выполняет запрос к базе данных. Если у вас сотня пунктов, то, соответственно, будут выполняться сто запросов и страница будет без кэширования выводиться несколько секунд. Для десяти пунктов это вполне оптимальный вариант. Альтернативный способ – единственная выборка всех пунктов вызовом findAll() в массив и уже рекурсивный обход этого массива. А более серьёзный – использование Nested Set.

Вместо повсеместного повторения конструкции str_repeat мы можем, например, создать ячейку IndentColumn с этим кодом и использовтаь её в любой таблице.

На сегодня с этим всё. Буду рад комментариям о данном решении.

Комментарии

 

Maseo – maseo.ru

А демку? Расписано хорошо, но думаешь сначала о nestedsets...

Ответить

 

Евгений – d-lera.com

Было бы неплохо добавить в статью скриншотов, что в итоге получилось. А то и не понятно, что же вы наделали, а садиться и писать код, что бы увидеть - сами понимаете, не самое лучшее решение.

Ответить

 

Евгений

Абсолютно поддерживаю
темы статей очень актуальные.... респект!
но отсутствие скриншотов - пичаль!

Ответить

 

Igor

При такой реализации будет неверно работать пагинатор - на странице будет выводиться по 10 родительских элементов, а число страниц в пагинаторе будет расчитываться от общего кол-ва элементов.

Ответить

 

Дмитрий Елисеев

Вполне вероятно. Попробую исправить.

Ответить

 

Алексей

Есть идеи как решить проблему с пагинатором?

Ответить

 

Дмитрий Елисеев

Можно добавить аналогичное доплнение

$сriteria->addCondition('t.parent_id iS NULL OR t.parent_id = 0');

внутрь метода 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 в скринкасте показывал.

Ответить

 

Вася Ветров

А можете ссылку или где искать скринкаст?

Ответить

 

Дмитрий Елисеев

Кликните по слову "скринкаст".

Ответить

 

Вася Ветров

) понял

Ответить

Оставить комментарий

Войти | Завести аккаунт | Войти через


(никто не увидит)





Можно использовать теги <p> <ul> <li> <b> <i> <a> <pre>