The nested set behaviour is an approach to store hierarchical data in relational databases. For example, if we have many categories for our product or items. One category can be a "parent" for other categories, means that one category consists of more than one category. The model can be drawn using a "tree" model. There are other approaches available but what we will learn in this article is specifically the NestedSetsBehavior made by Alexander Kochetov, which utilizing the Modified Preorder Tree Traversal algorithm.
Requirements :
- Yii2 framework advanced template
- Yii2 nested sets package
Install the package using composer
It is always recommended to use Composer to install any kind of package or extension for our Yii2-powered project.
$ composer require creocoder/yii2-nested-sets
Create the table
In this article, we will use Category
for our model/table name. So we would like to generate the table using our beloved migration tool.
$ ./yii migrate/create create_category_table
We need to modify the table so it contains our desired fields. We also generate three additional fields named position
, created_at
, and updated_at
.
<?php
use yii\db\Migration;
class m160611_114633_create_category extends Migration
{
public function up()
{
$this->createTable('category', [
'id' => $this->primaryKey(),
'name' => $this->string()->notNull(),
'tree' => $this->integer()->notNull(),
'lft' => $this->integer()->notNull(),
'rgt' => $this->integer()->notNull(),
'depth' => $this->integer()->notNull(),
'position' => $this->integer()->notNull()->defaultValue(0),
'created_at' => $this->integer()->notNull(),
'updated_at' => $this->integer()->notNull(),
]);
}
public function down()
{
$this->dropTable('category');
}
}
Then, generate the table using the migration tool.
$ ./yii migrate
If everything is okay, then you could see that a new table named category
already exists.
Generate the default CRUD using Gii
To initiate a model, we need to use Gii tool from Yii2. Call the tool from your localhost:8080/gii/model
, and fill in the Table Name field with our existing table: category
. Fill other fields with appropriate values, and don't forget to give a check to "Generate ActiveQuery" checklist item. This will generate another file that needs to be modified later.
Continue to generate the CRUD for our model with CRUD Generator Tool. Fill in each field with our existing model. After all files are generated, you can see that we already have models, controllers, and views but our work is far from done because we need to modify each file.
Modify models, controllers, and views
The first file we should modify is the model file: Category
.
<?php
namespace common\models;
use Yii;
use creocoder\nestedsets\NestedSetsBehavior;
class Category extends \yii\db\ActiveRecord
{
public static function tableName()
{
return 'category';
}
public function behaviors() {
return [
\yii\behaviors\TimeStampBehavior::className(),
'tree' => [
'class' => NestedSetsBehavior::className(),
'treeAttribute' => 'tree',
],
];
}
public function transactions()
{
return [
self::SCENARIO_DEFAULT => self::OP_ALL,
];
}
public static function find()
{
return new CategoryQuery(get_called_class());
}
public function rules()
{
return [
[['name'], 'required'],
[['position'], 'default', 'value' => 0],
[['tree', 'lft', 'rgt', 'depth', 'position', 'created_at', 'updated_at'], 'integer'],
[['name'], 'string', 'max' => 255],
];
}
public function attributeLabels()
{
return [
'id' => Yii::t('app', 'ID'),
'name' => Yii::t('app', 'Name'),
'tree' => Yii::t('app', 'Tree'),
'lft' => Yii::t('app', 'Lft'),
'rgt' => Yii::t('app', 'Rgt'),
'depth' => Yii::t('app', 'Depth'),
'position' => Yii::t('app', 'Position'),
'created_at' => Yii::t('app', 'Created At'),
'updated_at' => Yii::t('app', 'Updated At'),
];
}
public function getParentId()
{
$parent = $this->parent;
return $parent ? $parent->id : null;
}
public function getParent()
{
return $this->parents(1)->one();
}
public static function getTree($node_id = 0)
{
$children = [];
if ( ! empty($node_id))
$children = array_merge(
self::findOne($node_id)->children()->column(),
[$node_id]
);
$rows = self::find()->
select('id, name, depth')->
where(['NOT IN', 'id', $children])->
orderBy('tree, lft, position')->
all();
$return = [];
foreach ($rows as $row)
$return[$row->id] = str_repeat('-', $row->depth) . ' ' . $row->name;
return $return;
}
}
As you can see, we import the extension using the keyword use:
use creocoder\nestedsets\NestedSetsBehavior;
and I also add the TimeStampBehavior
for our additional fields, created_at
and updated_at
\yii\behaviors\TimeStampBehavior::className(),
Next : Our modification to CategoryController
file is at update
, create
, and delete
function.
<?php
namespace backend\controllers;
use Yii;
use common\models\Category;
use common\models\CategorySearch;
use yii\web\Controller;
use yii\web\NotFoundHttpException;
use yii\filters\VerbFilter;
class CategoryController extends Controller
{
public function behaviors()
{
return [
'verbs' => [
'class' => VerbFilter::className(),
'actions' => [
'delete' => ['POST'],
],
],
];
}
public function actionIndex()
{
$searchModel = new CategorySearch();
$dataProvider = $searchModel->search(Yii::$app->request->queryParams);
return $this->render('index', [
'searchModel' => $searchModel,
'dataProvider' => $dataProvider,
]);
}
public function actionView($id)
{
return $this->render('view', [
'model' => $this->findModel($id),
]);
}
public function actionCreate()
{
$model = new Category();
if ( ! empty(Yii::$app->request->post('Category')))
{
$post = Yii::$app->request->post('Category');
$model->name = $post['name'];
$model->position = $post['position'];
$parent_id = $post['parentId'];
if (empty($parent_id))
$model->makeRoot();
else
{
$parent = Category::findOne($parent_id);
$model->appendTo($parent);
}
return $this->redirect(['view', 'id' => $model->id]);
}
return $this->render('create', [
'model' => $model,
]);
}
public function actionUpdate($id)
{
$model = $this->findModel($id);
if ( ! empty(Yii::$app->request->post('Category')))
{
$post = Yii::$app->request->post('Category');
$model->name = $post['name'];
$model->position = $post['position'];
$parent_id = $post['parentId'];
if ($model->save())
{
if (empty($parent_id))
{
if ( ! $model->isRoot())
$model->makeRoot();
}
else
{
if ($model->id != $parent_id)
{
$parent = Category::findOne($parent_id);
$model->appendTo($parent);
}
}
return $this->redirect(['view', 'id' => $model->id]);
}
}
return $this->render('update', [
'model' => $model,
]);
}
public function actionDelete($id)
{
$model = $this->findModel($id);
if ($model->isRoot())
$model->deleteWithChildren();
else
$model->delete();
return $this->redirect(['index']);
}
protected function findModel($id)
{
if (($model = Category::findOne($id)) !== null) {
return $model;
} else {
throw new NotFoundHttpException('The requested page does not exist.');
}
}
}
As for our views files, we need to remove unnecessary fields from our form, such as the lft
, rgt
, etc, and add the sophisticated parent field to be a dropdown list. This requires a lot of effort, as you can see on the getTree
function on our model.
<?php
use yii\helpers\Html;
use yii\widgets\ActiveForm;
use common\models\Category;
?>
<div class="category-form">
<?php $form = ActiveForm::begin(); ?>
<?= $form->field($model, 'name')->textInput(['maxlength' => true]) ?>
<div class='form-group field-attribute-parentId'>
<?= Html::label('Parent', 'parent', ['class' => 'control-label']);?>
<?= Html::dropdownList(
'Category[parentId]',
$model->parentId,
Category::getTree($model->id),
['prompt' => 'No Parent (saved as root)', 'class' => 'form-control']
);?>
</div>
<?= $form->field($model, 'position')->textInput(['type' => 'number']) ?>
<div class="form-group">
<?= Html::submitButton($model->isNewRecord ? Yii::t('app', 'Create') : Yii::t('app', 'Update'), ['class' => $model->isNewRecord ? 'btn btn-success' : 'btn btn-primary']) ?>
</div>
<?php ActiveForm::end(); ?>
</div>