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;
/**
* Handles the creation for table `category`.
*/
class m160611_114633_create_category extends Migration
{
/**
* @inheritdoc
*/
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(),
]);
}
/**
* @inheritdoc
*/
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;
/**
* This is the model class for table "category".
*
* @property integer $id
* @property string $name
* @property integer $tree
* @property integer $lft
* @property integer $rgt
* @property integer $depth
* @property integer $position
* @property integer $created_at
* @property integer $updated_at
*/
class Category extends \yii\db\ActiveRecord
{
/**
* @inheritdoc
*/
public static function tableName()
{
return 'category';
}
public function behaviors() {
return [
\yii\behaviors\TimeStampBehavior::className(),
'tree' => [
'class' => NestedSetsBehavior::className(),
'treeAttribute' => 'tree',
// 'leftAttribute' => 'lft',
// 'rightAttribute' => 'rgt',
// 'depthAttribute' => 'depth',
],
];
}
public function transactions()
{
return [
self::SCENARIO_DEFAULT => self::OP_ALL,
];
}
public static function find()
{
return new CategoryQuery(get_called_class());
}
/**
* @inheritdoc
*/
public function rules()
{
return [
[['name'], 'required'],
[['position'], 'default', 'value' => 0],
[['tree', 'lft', 'rgt', 'depth', 'position', 'created_at', 'updated_at'], 'integer'],
[['name'], 'string', 'max' => 255],
];
}
/**
* @inheritdoc
*/
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'),
];
}
/**
* Get parent's ID
* @return \yii\db\ActiveQuery
*/
public function getParentId()
{
$parent = $this->parent;
return $parent ? $parent->id : null;
}
/**
* Get parent's node
* @return \yii\db\ActiveQuery
*/
public function getParent()
{
return $this->parents(1)->one();
}
/**
* Get a full tree as a list, except the node and its children
* @param integer $node_id node's ID
* @return array array of node
*/
public static function getTree($node_id = 0)
{
// don't include children and the node
$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;
/**
* CategoryController implements the CRUD actions for Category model.
*/
class CategoryController extends Controller
{
/**
* @inheritdoc
*/
public function behaviors()
{
return [
'verbs' => [
'class' => VerbFilter::className(),
'actions' => [
'delete' => ['POST'],
],
],
];
}
/**
* Lists all Category models.
* @return mixed
*/
public function actionIndex()
{
$searchModel = new CategorySearch();
$dataProvider = $searchModel->search(Yii::$app->request->queryParams);
return $this->render('index', [
'searchModel' => $searchModel,
'dataProvider' => $dataProvider,
]);
}
/**
* Displays a single Category model.
* @param integer $id
* @return mixed
*/
public function actionView($id)
{
return $this->render('view', [
'model' => $this->findModel($id),
]);
}
/**
* Creates a new Category model.
* If creation is successful, the browser will be redirected to the 'view' page.
* @return mixed
*/
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,
]);
}
/**
* Updates an existing Category model.
* If update is successful, the browser will be redirected to the 'view' page.
* @param integer $id
* @return mixed
*/
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 // move node to other root
{
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,
]);
}
/**
* Deletes an existing Category model.
* If deletion is successful, the browser will be redirected to the 'index' page.
* @param integer $id
* @return mixed
*/
public function actionDelete($id)
{
$model = $this->findModel($id);
if ($model->isRoot())
$model->deleteWithChildren();
else
$model->delete();
return $this->redirect(['index']);
}
/**
* Finds the Category model based on its primary key value.
* If the model is not found, a 404 HTTP exception will be thrown.
* @param integer $id
* @return Category the loaded model
* @throws NotFoundHttpException if the model cannot be found
*/
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;
/* @var $this yii\web\View */
/* @var $model common\models\Category */
/* @var $form yii\widgets\ActiveForm */
?>
<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>
Complete files can be found at my GitHub page: https://github.com/prabowomurti/learn-nested-set
The screencast (subtitled English) here : https://www.youtube.com/watch?v=MjJEjF1arHs
If you have any questions, please ask in the forum instead.
Signup or Login in order to comment.