thunderwolf/eloquent-nested-set

Nested Set for the Laravel Eloquent with tree_left, tree_right and tree_level based on Propel NestedSet and kalnoy/nestedset

1.0.1 2024-01-26 21:35 UTC

This package is auto-updated.

Last update: 2024-04-26 21:14:15 UTC


README

The nested_set behavior allows a model to become a tree structure, and provides numerous methods to traverse the tree in an efficient way.

Many applications need to store hierarchical data in the model. For instance, a forum stores a tree of messages for each discussion. A CMS sees sections and subsections as a navigation tree. In a business organization chart, each person is a leaf of the organization tree. Nested sets are the best way to store such hierarchical data in a relational database and manipulate it. The name “nested sets” describes the algorithm used to store the position of a model in the tree ; it is also known as “modified preorder tree traversal”.

To work it requires NestedSet trait in your Model with a configuration which in the most simple configuration just looks like this:

    public static function nestedSet(): array
    {
        return [];
    }

NestedSet trait you will be using overrides default Builder what can be seen below:

    /**
     * Override -> Create a new Eloquent query builder for the model.
     * If you have more Behaviors using this kind on Override create own and use Trait NestedSetBuilderTrait
     *
     * @param  Builder  $query
     * @return NestedSetBuilder
     */
    public function newEloquentBuilder($query): NestedSetBuilder
    {
        return new NestedSetBuilder($query);
    }

If you are using more Traits or own Override please just use NestedSetBuilderTrait trait as the NestedSetBuilder class just look like this:

<?php

namespace Thunderwolf\EloquentNestedSet;

use Illuminate\Database\Eloquent\Builder;

class NestedSetBuilder extends Builder
{
    use NestedSetBuilderTrait;
}

Basic Usage

The most basic way to use this package is to Create Model with the NestedSet trait in use like this:

<?php

use Illuminate\Database\Eloquent\Model;
use Thunderwolf\EloquentNestedSet\NestedSet;

class Section extends Model
{
    use NestedSet;

    protected $table = 'sections';

    protected $fillable = ['title'];

    public $timestamps = false;

    public static function nestedSet(): array
    {
        return [];
    }
}

After registering NestedSetServiceProvider you can also use Blueprints to create tables with a use of createNestedSet helper method similar to this:

You can also use Blueprints to create tables like this:

$schema->create('sections', function (Blueprint $table) {
    $table->increments('id');
    $table->string('title');
    $table->createNestedSet([]);
});

In similar way you will be working with migrations.

The model has the ability to be inserted into a tree structure, as follows:

<?php
$s1 = new Section();
$s1->setAttribute('title', 'Home');
$s1->makeRoot(); // make this node the root of the tree
$s1->save();
$s2 = new Section();
$s2->setAttribute('title', 'World');
$s2->insertAsFirstChildOf($s1); // insert the node in the tree
$s2->save();
$s3 = new Section();
$s3->setAttribute('title', 'Europe');
$s3->insertAsFirstChildOf($s2); // insert the node in the tree
$s3->save();
$s4 = new Section();
$s4->setAttribute('title', 'Business');
$s4->insertAsNextSiblingOf($s2); // insert the node in the tree
$s4->save();
/* The sections are now stored in the database as a tree:
    $s1:Home
    |       \
$s2:World  $s4:Business
    |
$s3:Europe
*/

You can continue to insert new nodes as children or siblings of existing nodes, using any of the insertAsFirstChildOf(), insertAsLastChildOf(), insertAsPrevSiblingOf(), and insertAsNextSiblingOf() methods.

Once you have built a tree, you can traverse it using any of the numerous methods the nested_set behavior adds to the query and model objects. For instance:

<?php
$rootNode = Section::query()->findRoot();       // $s1
$worldNode = $rootNode->getFirstChild();        // $s2
$businessNode = $worldNode->getNextSibling();   // $s4
$firstLevelSections = $rootNode->getChildren(); // array($s2, $s4)
$allSections = $rootNode->getDescendants();     // array($s2, $s3, $s4)

// you can also chain the methods
$europeNode = $rootNode->getLastChild()->getPrevSibling()->getFirstChild();  // $s3
$path = $europeNode->getAncestors();                                         // array($s1, $s2)

The nodes returned by these methods are regular Propel model objects, with access to the properties and related models. The nested_set behavior also adds inspection methods to nodes:

<?php
echo $s2->isRoot();      // false
echo $s2->isLeaf();      // false
echo $s2->getLevel();    // 1
echo $s2->hasChildren(); // true
echo $s2->countChildren(); // 1
echo $s2->hasSiblings(); // true

Each of the traversal and inspection methods result in a single database query, whatever the position of the node in the tree. This is because the information about the node position in the tree is stored in three columns of the model, named tree_left, tree_right, and tree_level. The value given to these columns is determined by the nested set algorithm, and it makes read queries much more effective than trees using a simple parent_id foreign key.

Manipulating Nodes

You can move a node - and its subtree - across the tree using any of the moveToFirstChildOf(), moveToLastChildOf(), moveToPrevSiblingOf(), and moveToNextSiblingOf() methods. These operations are immediate and don’t require that you save the model afterwards:

<?php
// move the entire "World" section under "Business"
$s2->moveToFirstChildOf($s4);
/* The tree is modified as follows:
$s1:Home
  |
$s4:Business
  |
$s2:World
  |
$s3:Europe
*/
// now move the "Europe" section directly under root, after "Business"
$s3->moveToNextSiblingOf($s4);
/* The tree is modified as follows:
    $s1:Home
    |        \
$s4:Business $s3:Europe
    |
$s2:World
*/

You can delete the descendants of a node using deleteDescendants():

<?php
// delete the entire "World" section of "Business"
$s4->deleteDescendants();
/* The tree is modified as follows:
    $s1:Home
    |        \
$s4:Business $s3:Europe
*/

If you delete() a node, all its descendants are deleted in cascade. To avoid accidental deletion of an entire tree, calling delete() on a root node throws an exception. Use the delete() Query method instead to delete an entire tree.

Filtering Results

The nested_set behavior adds numerous methods to the generated Query object. You can use these methods to build more complex queries. For instance, to get all the children of the root node ordered by title, build a Query as follows:

<?php
$rootNode = Section::query()->findRoot();
$children = Section::query()
    ->childrenOf($rootNode)
    ->orderBy('title')
    ->get();

Multiple Trees

When you need to store several trees for a single model - for instance, several threads of posts in a forum - use a scope for each tree. This requires that you enable scope tree support in the behavior definition by setting the use_scope parameter to true. Create Model with the NestedSet trait and a configuration like this:

<?php

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Thunderwolf\EloquentNestedSet\NestedSet;

class Post extends Model
{
    use NestedSet;

    protected $table = 'posts';

    protected $fillable = ['code', 'body'];

    public $timestamps = false;

    public static function nestedSet(): array
    {
        return ['use_scope' => true, 'scope_column' => 'posts_thread_id'];
    }

    public function thread(): BelongsTo
    {
        return $this->belongsTo(PostsThread::class, 'posts_thread_id');
    }
}

With this example we are also using a SingleScopedUser model like this:

<?php

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class PostsThread extends Model
{
    protected $table = 'posts-threads';

    protected $fillable = ['title'];

    public $timestamps = false;

    public function posts(): HasMany
    {
        return $this->hasMany(Post::class);
    }
}

For the upper models after registering NestedSetServiceProvider you can also use Blueprints to create tables with a use of createNestedSet helper method similar to this:

$schema->create('posts-threads', function (Blueprint $table3) {
    $table3->increments('id');
    $table3->string('title');
});

$schema->create('posts', function (Blueprint $table4) {
    $table4->increments('id');
    $table4->unsignedInteger('posts_thread_id');
    $table4->string('code');
    $table4->string('body');
    $table4->createNestedSet(['use_scope' => true, 'scope_column' => 'posts_thread_id']);
});

In similar way you will be working with migrations.

For the upper example you can have as many trees as required:

<?php
$thread1 = PostsThread::query()->find(1);
$thread2 = PostsThread::query()->find(2);
$thread3 = PostsThread::query()->find(3);

$firstPost = Post::query()->findRoot($thread2->getKey());  // first message of the discussion
$discussion = Post::query()->findTree($thread3->getKey()); // all messages of the discussion

// first messages of every discussion
$firstPostOfEveryDiscussion = Post::query()->findRoots();

Post::query()->inTree($thread1->getKey())->delete(); // delete an entire discussion

Configuring package

WARNING! scoped feature was not moved to the Eloquent and is only planed to be moved from Propel

By default, the behavior adds three columns to the model - four if you use the scope feature. You can use custom names for the nested sets columns.

Yu can also configure which columns will be created and used by the package like this:

<?php

use Illuminate\Database\Eloquent\Model;
use Thunderwolf\EloquentNestedSet\NestedSet;

class Category extends Model
{

    use NestedSet;

    protected $table = 'categories';

    protected $fillable = ['name'];

    public $timestamps = false;

    public static function nestedSet(): array
    {
        return ['left' => 'lft', 'right' => 'rgt', 'level' => 'lvl'];
    }

    public static function resetActionsPerformed()
    {
        static::$actionsPerformed = 0;
    }
}

and then your blueprint will look like this:

$schema->create('categories', function (Blueprint $table) {
    $table->increments('id');
    $table->string('name');
    $table->createNestedSet(['left' => 'lft', 'right' => 'rgt', 'level' => 'lvl']);
});

Complete API

Here is a list of the methods added by the behavior to the model objects:

<?php
// storage columns accessors
public function getLftName(): string
public function getRgtName(): string
public function getLvlName(): string
public function getLft(): ?int
public function setLft(int $value)
public function getRgt(): ?int
public function setRgt(int $value)
public function getLvl(): ?int
public function setLvl(int $value)

// only for behavior with use_scope
public function isNestedSetScopeUsed(): bool
public function getNestedSetScopeName(): string
public function getNestedSetScope(): ?int
public function setNestedSetScope(int $rank): void

// root maker (requires calling save() afterwards)
public function makeRoot(): self

// inspection methods
public function isInTree(): bool
public function isRoot(): bool
public function isLeaf(): bool
public function isDescendantOf(self $parent): bool
public function isAncestorOf(self $child): bool
public function hasParent(): bool
public function hasPrevSibling(): bool
public function hasNextSibling(): bool
public function hasChildren(): bool
public function countChildren(): int
public function countDescendants(): int

// tree traversal methods
public function getParent(): ?Model
public function getPrevSibling(): ?Model
public function getNextSibling(): ?Model
public function getChildren(): ?Collection
public function getFirstChild(): ?Model
public function getLastChild(): ?Model
public function getSiblings(bool $includeCurrent = false): ?Collection
public function getDescendants(): ?Collection
public function getBranch(): ?Collection
public function getAncestors(): ?Collection

// node insertion methods (immediate, no need to save() afterwards) - automatic object refresh
public function addChild(Model $child, string $where = 'first'): self

// node insertion methods (require calling save() afterwards)
public function insertAsFirstChildOf(Model $parent): self
public function insertAsLastChildOf(Model $parent): self
public function insertAsPrevSiblingOf(Model $sibling): self
public function insertAsNextSiblingOf(Model $sibling): self

// node move methods (immediate, no need to save() afterwards)
public function moveToFirstChildOf(Model $parent): self
public function moveToLastChildOf(Model $parent): self
public function moveToPrevSiblingOf(Model $sibling): self
public function moveToNextSiblingOf(Model $sibling): self

// deletion methods
public function deleteDescendants(): int

// refresh metod
public function reload(): Model

Here is a list of the methods added by the behavior to the Builder:

<?php
// tree filter methods
public function descendantsOf(Model $node): self
public function branchOf(Model $node): self
public function childrenOf(Model $node): self
public function siblingsOf(Model $node): self
public function ancestorsOf(Model $node): self
public function rootsOf(Model $node): self

// only for behavior with use_scope
public function treeRoots(): self
public function inTree(int $scope = null): self
public function findRoots()

// order methods
public function orderByBranch(bool $reverse = false): self
public function orderByLevel(bool $reverse = false): self

// termination methods
public function findRoot(int $scope = null)
public function findTree(int $scope = null)

// delete method
public function deleteTree($scope = null, ConnectionInterface $con = null): int