Заметки WEB-разработчика

Полезные материалы для web-разработки

Дерево категорий в yii с помощью Nested Set

В этой статье речь пойдет о том, как сформировать дерево категорий в yii с помощью компонента Nested Set.

Дерево категорий в yii с помощью Nested Set

Как известно, вывод вложенных категорий можно сделать с помощью рекурсии, но такой способ весьма ресурсоемкий и подходит лишь для небольших вложенностей. Если дело доходит до серьезной структуры, то наилучшим решением будет использовать алгоритм вложенные деревья(они же вложенные множества) или Nested Set (Nested set model).

Nested Set подразумевает присвоение каждому узлу в дереве двух дополнительных ключей left key и right key. Для заполнения этих ключей нужно полностью обойти всё дерево дважды посещая каждый из узлов. В результате выборка из дерева будет происходить довольно быстро.

Плюсы

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

Минусы

Изменения в базе занимают много времени, так как приходится обновлять все левые и правые ключи в записях, идущих после изменяемой.

Что касается, yii, то здесь мы имеем отличный компонент nested-set-behavior. Качаем и устанавливаем так, как написано в описании.

Далее я покажу как я делал crud для работы с категориями у себя в админке.

Для начала, код SQL самой таблицы категорий:

CREATE TABLE IF NOT EXISTS `tbl_category` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `lft` int(11) NOT NULL,
  `rgt` int(11) NOT NULL,
  `level` int(4) NOT NULL,
  `name` varchar(150) NOT NULL,
  `alias` varchar(150) NOT NULL,
  `title` varchar(150) NOT NULL,
  `meta_k` varchar(255) NOT NULL,
  `meta_d` text NOT NULL,
  `img` text NOT NULL,
  `order` tinyint(4) NOT NULL DEFAULT '0',
  `show` tinyint(1) NOT NULL DEFAULT '1',
  `txt` text,
  `cssclass` varchar(100) NOT NULL,
  `htmlview` varchar(100) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB	 DEFAULT CHARSET=utf8 AUTO_INCREMENT=48 ;

После создания таблицы, нужно создать модель Category:

class Category extends CActiveRecord
{
	public $parent_id;

	public function behaviors()
	{
		return array(
			'nestedSetBehavior'=>array(
				'class'=>'application.components.NestedSetBehavior',
				'leftAttribute'=>'lft',
				'rightAttribute'=>'rgt',
				'levelAttribute'=>'level',
			),
		);
	}

	public function tableName()
	{
		return '{{category}}';
	}

	public function rules()
	{
		return array(
			array('alias,name', 'required'),
			array('parent_id', 'numerical', 'integerOnly'=>true),
			// alias естесвенно должен быть обязательным, безопасным и уникальным	
			array('alias', 'match', 'pattern' => '/^[A-z\-\_]+$/'),
			array('alias','unique',
				'caseSensitive'=>true,
				'allowEmpty'=>false,
			),
			array('txt', 'safe'),
			//array('parent','length', 'max'=>1),
			array('lft, rgt, level, order, show', 'numerical', 'integerOnly'=>true),
			array('name, alias, title', 'length', 'max'=>150),
			array('meta_k', 'length', 'max'=>255),
			array('cssclass, htmlview', 'length', 'max'=>100),
			array('id, name, alias', 'safe', 'on'=>'search'),
		);
	}

	public function relations()
	{		
		return array();
	}

	public function attributeLabels()
	{
		return array(
			'id' => 'ID',
			'lft' => 'Lft',
			'rgt' => 'Rgt',
			'level' => 'Level',
			'name' => 'Name',
			'alias' => 'Alias',
			'title' => 'Title',
			'meta_k' => 'Meta K',
			'meta_d' => 'Meta D',
			'img' => 'Img',
			'order' => 'Order',
			'show' => 'Show',
			'txt' => 'Txt',
			'cssclass' => 'Cssclass',
			'htmlview' => 'Htmlview',
			'parent' => 'parent'
		);
	}

	public function search()
	{
		$criteria=new CDbCriteria;

		$criteria->compare('id',$this->id);
		// и т.д.

		return new CActiveDataProvider($this, array(
			'criteria'=>$criteria,
		));
	}

	public static function model($className=__CLASS__)
	{
		return parent::model($className);
	}

	/**
	 * Создает корневую категорию либо возвращает уже имеющуюся
	 * @param Category $model
	 * @return mixed
	 */
	public static function getRoot(Category $model){
		$root = $model->roots()->find();
		if (! $root){
			$model->name = 'Категории';
			$model->alias = 'Root';
			$model->title = 'Категории';
			$model->meta_k = 'Категории';
			$model->meta_d = 'Категории';
			$model->txt = 'Категории';
			$model->saveNode();
			$root = $model->roots()->find();
		}
		return $root;
	}
}

Теперь настала очередь контроллера Category.

class CategoryController extends AdminController
{
	public function actionView($id)
	{
		$arr_ancestors = array();

		$category = Category::model()->find(array(
			'condition' => 'id=:id',
			'params' => array(':id' => $id),
		));

		$ancestors = $category->ancestors()->findAll();

		foreach($ancestors as $ancestor){
			$arr_ancestors[] = $ancestor->name;
		}

		$this->render('view',array(
			'arr_ancestors' => $arr_ancestors,
			'model'=>$this->loadModel($id),
		));
	}


	public function actionCreate()
	{
		$model = new Category;
		$root = Category::getRoot($model);
		$descendants = $root->descendants()->findAll();

		if(isset($_POST['Category']))
		{
			$parent_id = (int)$_POST['Category']['parent_id'];
			$root = Category::model()->findByPk($parent_id);
			$model->attributes = $_POST['Category'];
			if($model->appendTo($root)){
				$this->redirect(array('view','id'=>$model->id));
			}
		}

		$this->render('create',array(
			'model'=>$model,
			'root' => $root,
			'categories' => $descendants,
			'parent_id' => null,
			'id' => null,
		));
	}


	public function actionUpdate($id)
	{
		$root = Category::getRoot(new Category);
		$descendants = $root->descendants()->findAll();

		$model = $this->loadModel($id);

		$parent = $model->parent()->find();
		$parent_id = $parent ? $parent->id : null;

		if(isset($_POST['Category']))
		{
			$parent_id = (int)$_POST['Category']['parent_id'];

			$node = Category::model()->findByPk($parent_id);

			$model->attributes = $_POST['Category'];

			if($model->lft == 1 || $model->id == $node->id){
				if($model->saveNode()){
					Yii::app()->user->setFlash('category_error', "Структура дерева не изменена.");
					$this->redirect(array('view','id'=>$model->id));
				}
			}
			else{
				if($model->saveNode()){
					if($node->isDescendantOf($model)){
						Yii::app()->user->setFlash('category_error', "Структура дерева не изменена.");
					}
					else{
						$model->moveAsLast($node);
					}
					$this->redirect(array('view','id'=>$model->id));
				}
			}
		}

		$this->render('update',array(
			'model'=> $model,
			'root' => $root,
			'categories' => $descendants,
			'parent_id' => $parent_id,
			'id' => $id,
		));
	}


	public function actionDelete($id)
	{
		$this->loadModel($id)->deleteNode();
		// if AJAX request (triggered by deletion via admin grid view), we should not redirect the browser
		if(!isset($_GET['ajax']))
			$this->redirect(isset($_POST['returnUrl']) ? $_POST['returnUrl'] : array('index'));
	}


	public function actionIndex()
	{
		$model = new Category;
		$root = Category::getRoot($model);
		$descendants = $root->descendants()->findAll();

		$this->render('index',array(
			'root' => $root,
			'categories' => $descendants,
		));
	}


	public function actionAdmin()
	{
		$model=new Category('search');
		$model->unsetAttributes();
		if(isset($_GET['Category']))
			$model->attributes=$_GET['Category'];

		$this->render('admin',array(
			'model'=>$model,
		));
	}

	public function loadModel($id)
	{
		$model=Category::model()->findByPk($id);
		if($model===null)
			throw new CHttpException(404,'The requested page does not exist.');
		return $model;
	}

}

Что касается вьюшек. Итак, вид index. Здесь список формируется тегв ul li:

<div class="admin category index">
  <?php
  $this->breadcrumbs=array(
   'Дерево категорий',
  );
  $this->menu=array(
   array('label'=>'Создать', 'url'=>array('create')),
   array('label'=>'Менеджер', 'url'=>array('admin')),
  );
  ?>
  <h1>Дерево категорий</h1>
  [<?=$root->id?>] <?=$root->name?>
  <a class="view" title="View" href="<?=Yii::app()->createUrl('/admin/category/view', array('id'=>$root->id))?>">view</a>
  <a class="update" title="Update" href="<?=Yii::app()->createUrl('/admin/category/update', array('id'=>$root->id))?>">update</a>
  <?php echo CHtml::link('delete" alt="Delete">',"#", array("submit"=>array('delete', 'id'=>$root->id), 'confirm' => 'Are you sure?')); ?>
  <?
  $level=0;
  foreach($categories as $n=>$category)
  {
  if($category->level==$level)
  echo '</li>';
  else if($category->level>$level)
  echo '<ul>';
  else
  {
  echo '</li>';
  for($i=$level-$category->level;$i;$i--)
  {
  echo '</ul>';
  echo '</li>';
  }
  }
  ?>
  <li>
  [<?=$category->id?>] <?=$category->name?>
  <a class="view" title="View" href="<?=Yii::app()->createUrl('/admin/category/view', array('id'=>$category->id))?>">view</a>
  <a class="update" title="Update" href="<?=Yii::app()->createUrl('/admin/category/update', array('id'=>$category->id))?>">update</a>
  <?php echo CHtml::link('delete',"#", array("submit"=>array('delete', 'id'=>$category->id), 'confirm' => 'Are you sure?')); ?>
  <?
  $level=$category->level;
  }
  ?>
  <? for($i=$level;$i;$i--): ?>
  </li>
  </ul>
  <? endfor;?>
</div>

Также рекомендую использовать jquery плагин treeview для более удобного и красивого вывода деревав вашем html-виде.

А теперь вид _form. Здесь важен только вывод выпадающего списка, но я уж приведу всё:

<?php
  /* @var $this CategoryController */
  /* @var $model Category */
  /* @var $form CActiveForm */
  ?>
<div class="form">
<?php $form=$this->beginWidget('CActiveForm', array(
  'id'=>'category-form',
  // Please note: When you enable ajax validation, make sure the corresponding
  // controller action is handling ajax validation correctly.
  // There is a call to performAjaxValidation() commented in generated controller code.
  // See class documentation of CActiveForm for details on this.
  'enableAjaxValidation'=>false,
  )); ?>
 <p class="note">Fields with <span class="required">*</span> are required.</p>
 <?php echo $form->errorSummary($model); ?>
 <div class="row">
  <?php echo $form->labelEx($model,'name'); ?>
  <?php echo $form->textField($model,'name',array('size'=>60,'maxlength'=>150)); ?>
  <?php echo $form->error($model,'name'); ?>
  </div>
 <div class="row">
  <?php echo $form->labelEx($model,'alias'); ?>
  <?php echo $form->textField($model,'alias',array('size'=>60,'maxlength'=>150)); ?>
  <?php echo $form->error($model,'alias'); ?>
  </div>
 <div class="row">
  <?php echo $form->labelEx($model,'title'); ?>
  <?php echo $form->textField($model,'title',array('size'=>60,'maxlength'=>150)); ?>
  <?php echo $form->error($model,'title'); ?>
  </div>
 <div class="row">
  <?php echo $form->labelEx($model,'meta_k'); ?>
  <?php echo $form->textField($model,'meta_k',array('size'=>60,'maxlength'=>255)); ?>
  <?php echo $form->error($model,'meta_k'); ?>
  </div>
 <div class="row">
  <?php echo $form->labelEx($model,'meta_d'); ?>
  <?php echo $form->textArea($model,'meta_d',array('rows'=>6, 'cols'=>50)); ?>
  <?php echo $form->error($model,'meta_d'); ?>
  </div>
 <div class="row">
  <?php echo $form->labelEx($model,'order'); ?>
  <?php echo $form->textField($model,'order'); ?>
  <?php echo $form->error($model,'order'); ?>
  </div>
 <div class="row">
  <?php echo $form->labelEx($model,'show'); ?>
  <?php echo $form->textField($model,'show'); ?>
  <?php echo $form->error($model,'show'); ?>
  </div>
 <div class="row">
  <?php echo $form->labelEx($model,'txt'); ?>
  <?php echo $form->textArea($model,'txt',array('rows'=>6, 'cols'=>50)); ?>
  <?php echo $form->error($model,'txt'); ?>
  </div>
 <div class="row">
  <?php echo $form->labelEx($model,'cssclass'); ?>
  <?php echo $form->textField($model,'cssclass',array('size'=>60,'maxlength'=>100)); ?>
  <?php echo $form->error($model,'cssclass'); ?>
  </div>
 <div class="row">
  <?php echo $form->labelEx($model,'htmlview'); ?>
  <?php echo $form->textField($model,'htmlview',array('size'=>60,'maxlength'=>100)); ?>
  <?php echo $form->error($model,'htmlview'); ?>
  </div>
 <div class="row">
  <?php echo $form->labelEx($model,'parent_id'); ?>
  <select name="Category[parent_id]" id="Category_parent">
   <option value="<?=$root->id ?>"><?=$root->name?></option>
   <? if (!empty($categories)) : ?>
	<? foreach ($categories as $category) : ?>
	 <option value="<?=$category->id ?>"
	 <?=!empty($_POST['parent']) && $_POST['parent']== $category->id || $parent_id == $category->id? 'selected="selected"' : ''?>>
	 <?=str_repeat('-', $category->level), $category->name?>
	</option>
   <? endforeach; ?>
   <? endif;?>
  </select>
 </div>
 <div class="row buttons">
  <?php echo CHtml::submitButton($model->isNewRecord ? 'Create' : 'Save'); ?>
  </div>
<?php $this->endWidget(); ?>
</div><!-- form -->

На этом всё. Удачи, други!

Комментарии

Комментарии через Вконтакте