nestedsetbehavior

Nested set behavior for AR models
102 followers

Note: Latest release and documentation are available from extension github page.

This extension allows managing trees stored in database as nested sets. It's implemented as Active Record behavior.

Implemented by creocoder.

Resources

Documentation

Installing and configuring

First you need to configure model as follows:

public function behaviors()
{
    return array(
        'NestedSetBehavior'=>array(
            'class'=>'ext.yiiext.behaviors.trees.NestedSetBehavior',
            'leftAttribute'=>'lft',
            'rightAttribute'=>'rgt',
            'levelAttribute'=>'level',
    );
}

There is no need to validate fields specified in leftAttribute, rightAttribute, rootAttribute and levelAttribute options. Moreover, there could be problems if there are validation rules for these. Please check if there are no rules for fields mentioned in model's rules() method.

In case of storing a single tree per database, DB structure can be built with extensions/yiiext/behaviors/trees/schema.sql. If you're going to store multiple trees you'll need extensions/yiiext/behaviors/trees/schema_many_roots.sql.

By default leftAttribute, rightAttribute and levelAttribute values are matching filed names in default DB schemas so you can skip configuring these.

There are two ways this behavior can work: one tree per table and multiple trees per table. The mode is selected based on the value of hasManyRoots option that is false by default meaning single tree mode. In multiple trees mode you can set rootAttribute option to match existing filed in the table storing the tree.

Selecting from a tree

In the following we'll use an example model Category with the following in its DB:

- 1. Mobile phones
    - 2. iPhone
    - 3. Samsung
        - 4. X100
        - 5. C200
        - 6. Motorola
- 7. Cars
    - 8. Audi
    - 9. Ford
    - 10. Mercedes

In this example we have two trees. Tree roots are ones with ID=1 and ID=7.

Getting all roots

Using NestedSetBehavior::roots():

$roots=Category::model()->roots()->findAll();

Result:

Array of Active Record objects corresponding to Mobile phones and Cars nodes.

Getting all descendants of a node

Using NestedSetBehavior::descendants():

$category=Category::model()->findByPk(1);
$descendants=$category->descendants()->findAll();

Result:

Array of Active Record objects corresponding to iPhone, Samsung, X100, C200 and Motorola.

Getting all children of a node

Using NestedSetBehavior::children():

$category=Category::model()->findByPk(1);
$descendants=$category->children()->findAll();

Result:

Array of Active Record objects corresponding to iPhone, Samsung and Motorola.

Getting all ancestors of a node

Using NestedSetBehavior::ancestors():

$category=Category::model()->findByPk(5);
$descendants=$category->ancestors()->findAll();

Result:

Array of Active Record objects corresponding to Samsung and Mobile phones.

Getting parent of a node

Using NestedSetBehavior::getParent():

$category=Category::model()->findByPk(9);
$parent=$category->parent;

Result:

Array of Active Record objects corresponding to Cars.

Getting node siblings

Using NestedSetBehavior::getPrevSibling() or NestedSetBehavior::getNextSibling():

$category=Category::model()->findByPk(9);
$nextSibling=$category->nextSibling;

Result:

Array of Active Record objects corresponding to Mercedes.

Getting the whole tree

You can get the whole tree using standard AR methods like the following.

For single tree per table:

Category::model()->findAll(array('order'=>'lft'));

For multiple trees per table:

Category::model()->findAll(array('condition'=>'root_id=?','order'=>'lft'),array($root_id));

Modifying a tree

In this section we'll build a tree like the one used in the previous section.

Creating root nodes

You can create a root node using NestedSetBehavior::saveNode(). In a single tree per table mode you can create only one root node. If you'll attempt to create more there will be CException thrown.

$root=new Root;
$root->title='Mobile Phones';
$root->saveNode();
$root=new Root;
$root->title='Cars';
$root->saveNode();

Result:

- 1. Mobile Phones
- 2. Cars

Adding child nodes

There are multiple methods allowing you adding child nodes. To get more info about these refer to API. Let's use these to add nodes to the tree we have:

$category1=new Category;
$category1->title='Ford';
$category2=new Category;
$category2->title='Mercedes';
$category3=new Category;
$category3->title='Audi';
$root=Category::model()->findByPk(1);
$category1->appendTo($root);
$category2->insertAfter($category1);
$category3->insertBefore($category1);

Result:

- 1. Mobile phones
    - 3. Audi
    - 4. Ford
    - 5. Mercedes
- 2. Cars

Logically the tree above doesn't looks correct. We'll fix it later.

$category1=new Category;
$category1->title='Samsung';
$category2=new Category;
$category2->title='Motorola';
$category3=new Category;
$category3->title='iPhone';
$root=Category::model()->findByPk(2);
$category1->appendTo($root);
$category2->insertAfter($category1);
$category3->prependTo($root);

Result:

- 1. Mobile phones
    - 3. Audi
    - 4. Ford
    - 5. Mercedes
- 2. Cars
    - 6. iPhone
    - 7. Samsung
    - 8. Motorola
$category1=new Category;
$category1->title='X100';
$category2=new Category;
$category2->title='C200';
$node=Category::model()->findByPk(3);
$category1->appendTo($node);
$category2->prependTo($node);

Result:

- 1. Mobile phones
    - 3. Audi
        - 9. С200
        - 10. X100
    - 4. Ford
    - 5. Mercedes
- 2. Cars
    - 6. iPhone
    - 7. Samsung
    - 8. Motorola

Modifying a tree

In this section we'll finally make our tree logical.

Tree modification methods

There are several methods allowing you to modify a tree. To get more info about these refer to API.

Let's start:

// move phones to the proper place
$x100=Category::model()->findByPk(10);
$c200=Category::model()->findByPk(9);
$samsung=Category::model()->findByPk(7);
$x100->moveAsFirst($samsung);
$c200->moveBefore($x100);
// now move all Samsung phones branch
$mobile_phones=Category::model()->findByPk(1);
$samsung->moveAsFirst($mobile_phones);
// move the rest of phone models
$iphone=Category::model()->findByPk(6);
$iphone->moveAsFirst($mobile_phones);
$motorola=Category::model()->findByPk(8);
$motorola->moveAfter($samsung);
// move car models to appropriate place
$cars=Category::model()->findByPk(2);
$audi=Category::model()->findByPk(3);
$ford=Category::model()->findByPk(4);
$mercedes=Category::model()->findByPk(5);
 
foreach(array($audi,$ford,$mercedes) as $category)
    $category->moveAsLast($cars);

Result:

- 1. Mobile phones
    - 6. iPhone
    - 7. Samsung
        - 10. X100
        - 9. С200
    - 8. Motorola
- 2. Cars
    - 3. Audi
    - 4. Ford
    - 5. Mercedes

Moving a node making it a new root

There is a special moveAsRoot() method that allows moving a node and making it a new root. All descendants are moved as well in this case.

Example:

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

Identifying node type

There are three methods to get node type: isRoot(), isLeaf(), isDescendantOf().

Example:

$root=Category::model()->findByPk(1);
CVarDumper::dump($root->isRoot()); //true;
CVarDumper::dump($root->isLeaf()); //false;
$node=Category::model()->findByPk(9);
CVarDumper::dump($node->isDescendantOf($root)); //true;
CVarDumper::dump($node->isRoot()); //false;
CVarDumper::dump($root->isLeaf()); //true;
$samsung=Category::model()->findByPk(7);
CVarDumper::dump($node->isDescendantOf($samsung)); //true;

Useful code

Non-recursive tree traversal

$level=0;
 
foreach($categories as $n=>$category)
{
    if($category->level==$level)
        echo CHtml::closeTag('li')."\n";
    else if($category->level>$level)
        echo CHtml::openTag('ul')."\n";
    else
    {
        echo CHtml::closeTag('li')."\n";
 
        for($i=$level-$model->level;$i;$i--)
        {
            echo CHtml::closeTag('ul')."\n";
            echo CHtml::closeTag('li')."\n";
        }
    }
 
    echo CHtml::openTag('li');
    echo CHtml::encode($category->title);
    $level=$category->level;
}
 
for($i=$level;$i;$i--)
{
    echo CHtml::closeTag('li')."\n";
    echo CHtml::closeTag('ul')."\n";
}

Change Log

1.05

  • External transaction support for NestedSetBehavior::delete() method (creocoder)

1.04

  • Fix for $depth parameter of NestedSetBehavior::ancestors() method (creocoder)

1.03

  • Class renamed to NestedSetBehavior (creocoder)
  • Rare bug with cache correction after NestedSetBehavior::addNode() fixed (creocoder)
  • Readmes non-recursive tree traversal algorithm fix (creocoder)

1.02

  • Added moveAsRoot() method (creocoder)
  • Updated readmes (Sam Dark)

1.01

  • Added not integer pk support (creocoder)
  • Remove final from class (creocoder)

1.00

  • Some behavior refactoring before release (creocoder)
  • Advanced doc added (creocoder)

0.99b

  • Allow to use multiply change tree operations (creocoder)
  • Method saveNode() now create root in "single root mode" (creocoder)
  • Unit tests refactored (creocoder)
  • Some behavior refactoring (creocoder)

0.99

  • Added note about removing fields from rules (Sam Dark)
  • Added Unit tests for single root mode (creocoder)
  • All attributes are now correctly quoted (creocoder)
  • Renamed fields: 'root'=>'rootAttribute', 'left'=>'leftAttribute', 'right'=>'rightAttribute', 'level'=>'levelAttribute' (creocoder)
  • Renamed method parent() => getParent() (creocoder)

0.95

  • Unit tests added (creocoder)
  • Incorrect usage of save() and delete() instead of behavior's saveNode() and deleteNode() is now detected (creocoder)

0.90

  • Moving a node to a different root is now supported (creocoder)

0.85

  • Initial public release (creocoder)

Total 20 comments

#11023 report it
datevid at 2012/12/10 10:25pm
La documentacion está muy pobre

debido a que no tenemos demasiada informacion aqui, es necesario ver el código directamente, pues es el lugar donde mencionan los parametros adicionales al momento de crear nodos sin realizar la validación. here

$root = new Menu();
// $rolft = 1;
// $root->rgt = 2;
// $root->level = 1;
$root->text = 'root';
 
$root->saveNode( false );

and

$help = new Menu();
    $help->text = 'Help';
    $help->enabled = true;
    $appended = $help->appendTo( $root, false );
#10514 report it
__construct() at 2012/11/01 01:33pm
Wrong data structure visualization in this example

Just look closer to the "Selecting from tree" section and "Getting all children of a node" example.

Example data structure:

- 2. iPhone
    - 3. Samsung
        - 4. X100
        - 5. C200
        - 6. Motorola

but CORRECT example data structure shoud be:

- 2. iPhone
    - 3. Samsung
        - 4. X100
        - 5. C200
    - 6. Motorola

Please fix the extension description and the readme file on github.

BTW - this is a great extension anyway - thank you!

#9884 report it
CNGOD at 2012/09/19 04:18am
Recursive traversal how to do?

Can you give a recursive traversal of useful code?

#9620 report it
julianm at 2012/08/29 12:34pm
JOIN with Categories

Great extension. I got it work! But I have a question:

If I have a tbl_presentation and categories extension configured for these presentations. How can I get all the the categories (roots for example) for those presentations that have certain condition? (ie: tbl_presentation.status = 'active') ?

#8995 report it
CeBe at 2012/07/11 07:35pm
github page is fixed

@andrew1 just fixed the error.

#8994 report it
andrew1 at 2012/07/11 06:44pm
Can't access on github

Would love to be able to use this extension but can't access it on the link provided to github.

Getting Fatal error: Cannot use object of type stdClass as array in /var/lib/jenkins/workspace/yiiext.github.com/app/components/YiiextGithubApi.php on line 72

#7747 report it
Cstdenis at 2012/04/14 06:22am
Looks useful

Could benefit from using of Postgresql's ltree data type where available: http://www.postgresql.org/docs/9.1/static/ltree.html

#7641 report it
br0sk at 2012/04/04 07:26am
Memory problem when using a large number of models instances

The Problem

I have noticed a bit of a problem with it if you use it for exports or similar tasks where you are traversing through the whole database getting data and pushing it out to a file.

The extension has an internal cache stored in a static variable. This can be disastrous since this means that the memory of the model you just used will be stored in memory and because of that is not released when you loop through to the next element.

I was traversing through about 100000 items and after adding a call to a model that uses the nested set behaviour my export script started running out of memory.

I am using a call like this in a loop:

$ancestor = $this->ancestors(1)->find();

If you look in the afterFind Method in the nested set behaviour it does this:

$owner=$this->getOwner();
self::$_cached[get_class($owner)][$this->_id=self::$_c++]=$owner;

This stores the object in a static array. Quite quickly this will fill the memory up. This problem is probably only ever going to show up in the rare event of you traversing through a large number of nodes and I can see that this is the wanted behaviour in most cases.

The Solution

Make sure to destruct the model manually after you have used it. Like so:

$ancestor->__destruct();

The nested set behaviour has a destructor that removes the object from the static cache.

unset(self::$_cached[get_class($this->getOwner())][$this->_id]);

Suggestion

Add possibility to turn caching off in the behaviour.

Something like:

public function behaviors()
    {
      return array(
            'NestedSetBehavior'=>array(
                'class'=>'application.components.behaviors.trees.NestedSetBehavior',
                'leftAttribute'=>'left',
                'rightAttribute'=>'right',
                'levelAttribute'=>'level',
                'hasManyRoots'=>true,
                'cache'=>false
        ));
    }
#6778 report it
McSilvio at 2012/02/04 06:12pm
Multiple Users

Hi there, I'd like to attach a user_id field to this table and have 1 tree per user. Do you think this plugin will work in this way?

Thanks for the great work, Marco.

#6701 report it
naser at 2012/01/30 06:38am
use it with CMenu

hi, thanks for your great job is there a best solution to use it with CMenu?

#5491 report it
yiqing95 at 2011/10/16 12:54am
pagination with nestedset

when a top level root has too many children , if want to use the pagination . how to ? i noticed this method : public function descendants($depth=null, $self = false)

too many children means the tree may be too deep or too wide . both situation need consider how to apply pagination , any idea ?

#5003 report it
drumaddict at 2011/09/05 08:26am
Nested Set Behavior Administration GUI with jsTree

Hi,I have just uploaded an extension for Nested Set Behavior Administration with jsTree plugin.This is the Link: Nested Set Behavior Administration GUI with jsTree

#4978 report it
Spear at 2011/09/02 06:01pm
How to build a CTreeView

A very useful extension, thank you so much for sharing! Could you please help me get this clear — how exactly can I display the whole tree via CTreeView widget? What is the right way to get the whole tree, when three has many roots?

#4678 report it
yiqing95 at 2011/08/03 02:18pm
glad to see the example about how to use this fantastic extension.

the usage example is very helpful , also we can check it out from svn , in the unit test directory one can find more usage!

how about developing an extension that bridge the nestedset model and the adjacency model , we can translate one model easily to another . there are some code pieces in the internet converting the nestedset to adjacency model . you need create both table and config the mapping for two table fields( because some table may be exist earlier and may have different field name ) ,often converting take place in midnight ,it will cost a lot of time .

#4546 report it
Mohit at 2011/07/19 09:28am
jqGrid Tree with Nested Sets using multiple roots

I just implemented jqGrid Tree view using Nested Sets with multiple nodes. It took me sometime to work out the logic, so i thought i could share my code, if some other beginner like me needs to check.

Using Nested Sets in multiple roots mode requires the use of the 'adjacency' jqGrid tree Model. Atleast that is what i understand.

The view file details:

<?php
Yii::app()->clientScript->registerCoreScript('jquery.ui');
Yii::app()->clientScript->registerCssFile(Yii::app()->baseUrl . '/css/ui-lightness/jquery-ui-1.8.13.custom.css');
Yii::app()->clientScript->registerCssFile(Yii::app()->baseUrl . '/css/ui.jqgrid.css');
Yii::app()->clientScript->registerScriptFile(Yii::app()->baseUrl . '/js/jqGrid/i18n/grid.locale-en.js', CClientScript::POS_HEAD);
Yii::app()->clientScript->registerScriptFile(Yii::app()->baseUrl . '/js/jqGrid/jquery.jqGrid.min.js', CClientScript::POS_HEAD);
?>
<table id="list"></table> 
<div id="pager"></div> 
<?php
$script = '
    jQuery("#list").jqGrid(
    {
        treeGrid: true,
        treeGridModel: "adjacency",
        ExpandColumn: "name",
        rowNum: -1,
        url: "' . $this->createUrl("fetchTree") . '",
        datatype: "json",
        treedatatype: "json",
        treeIcons: {plus:"ui-icon-plus",minus:"ui-icon-minus",leaf:"ui-icon-cancel"},
        mtype: "POST",
        ExpandColClick: true,
        colNames:["id", "Group Name"],
        colModel :
            [ 
                {name:"id", index:"id", width:1, hidden: true, key: true},
                {name:"name", index:"name", width:300, sortable: false}
            ],
        height: "auto",
        caption: "View Groups"
    });
';
 
Yii::app()->clientScript->registerScript('jqGrid',$script);
?>

The actionFetchTree method:

public function actionFetchTree()
    {
        $ADDWHERE = '';
        if (isset ($_REQUEST["nodeid"]))
            $node = (integer)$_REQUEST["nodeid"];
        else
            $node = 0;
        // detect if here we post the data from allready loaded tree
        // we can make here other checks
        if( $node >0) 
        {
            //in jqGrid the level starts from 0, but in nested sets with multiple roots the level starts from 1
            $n_lvl = (integer)$_REQUEST["n_level"] + 1;         
            $parent = Yii::app()->db
                    ->createCommand("Select id, root, lft, rgt from tbl_tree WHERE id = $node")
                    ->queryRow();
            $ADDWHERE = " root = " . $parent["root"] . " AND level = " . ($n_lvl+1) . " AND lft > " . $parent["lft"] . " AND rgt < " . $parent["rgt"];
        } 
        else 
        { 
            // initial grid
            $n_lvl =0;
            $ADDWHERE = " level = 1 ";
        }        
        $SQL1 = "SELECT id, root, name, level-1 as lvl, lft, rgt, is_ledger from tbl_tree WHERE $ADDWHERE order by root, lft";
 
        header("Content-type: text/html;charset=utf-8");
        $response->page = 1;
        $response->total = 1;
        $cmd = Yii::app()->db->createCommand($SQL1);
        $result = $cmd->queryAll();
        $i=0;
        foreach ($result as $row)
        {
            if ($row['is_ledger'] == true) $leaf = 'true';else $leaf='false';
            if ($row['lvl'] == 0) $root = NULL; else $root = $parent['id'];
            $l = (integer)$row['lvl'];
            if( $n_lvl ==  $l)
            {
                $response->rows[$i]['cell']=array
                    (
                        $row['id'],
                        $row['name'],
                        $n_lvl,
                        $root,
                        $leaf,
                        'false'
                    );
                $i++;
            }
        }
        echo json_encode($response);
    }

The explanation is given in the jqGrid wiki site where the usage of Adjacency Model is described. The only point to remember here is that while sending the children nodes we need to send the immediate parent id, and not the root id for that tree. So, in the beginning of the code, we try to obtain the root details through a queryRow() call

#4520 report it
Mohit at 2011/07/16 07:41am
Transaction Checking while deleting node

This is really a great extension.

Please notice that while performing the moveNode action, the checking is being done to find out if a txn has already been set (i.e., if there was a beginTransaction() issued by some controller). And only if it has not been set, the extension tries to set one by itself.

But while deleting a Node, the same is not being done. I feel that this checking could have been done in the delete method also. In my implementation, i have enabled this checking and it is working fine, as of now.

#4468 report it
mindplay at 2011/07/11 10:33pm
Fantastic work!

Beautiful work!

One note, this is technically not a nested set, but an adjacency list - this type of tree implementation is optimized for fast reading, but writes are more expensive than a nested set. It also had the advantage (over a nested set) of maintaining sibling order.

Personally I prefer adjacency lists - and this is an awesome implementation, queries neatly packaged as scopes, operations fully wrapped in transactions, fully unit tested, etc. - very, very nice work!

Thanks!

#4310 report it
hav3fun at 2011/06/24 07:50am
Several trees in the same table, is it possible?

First of all, great extension.

Now I need to extend its functionality in order to keep several trees in the same table. Not one tree with several roots, but several trees within the same table. I would need this "profile_id" filter to paint one tree or another.

I have a table "profile_component" with fields "id", "left", "right" and "level" to manage the nested set behavior. But it also has a field "profile_id", so that the records with profile_id=0 represent one tree, the records with profile_id=1 represent a second tree, and so on.

Does anybody know how should I extend the component so that I can get one tree or another by setting this "profile_id" attribute?

I´m using also JsTree to render the tree and EJNestedTreeActions to deal with the tree actions.

Thanks in advance

#4180 report it
yiqing95 at 2011/06/14 03:10am
want to know does it can apply to different ActiveRecord class?

in a project there may have many models , which have the tree struct . and if this extension can apply to multiple ar or can only to one specific ar . if attach to many different ar , does it need to modify the category table schema , may be add some field like : cate_type varchar(20) .. or i misunderstand the usage , i will modify the ar's table add the table field come from the category .
the tree struct can added to a exist table(which in first version may be plain ) ,let's say : menu(id,text,url). some time later , we need the menu to support tree struct , there are two choices : 1.add some another fields to the menu table ,like pid, level etc..; 2.- use another table such as node to maintain the relation :node(id ,type, id_type,type_pid,type_level)
the choice 2 may need do a lots things when perform the crud operations. for query may use the left join or inner join . and create , update , delete become even complex . so if i use this extension in my project , i have to modify the existing ar's table.

#4027 report it
mc.aser at 2011/05/30 02:11pm
How track event change node?, move, create, update, delete?

How track event change node, moved, created, updated, deleted? Event onAfterSave not work! I need delete cache if changed structur of tree. And it is I have to do in model.

Thanks :) !

Leave a comment

Please to leave your comment.

Create extension
Downloads
No downloadable files yet