Skip to content

Commit

Permalink
#50: implemented tree rebuilding feature
Browse files Browse the repository at this point in the history
  • Loading branch information
lazychaser committed Feb 24, 2016
1 parent 06237a6 commit d2a95b3
Show file tree
Hide file tree
Showing 6 changed files with 224 additions and 35 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
`has('parent')` instead
* Default order is no longer applied for `siblings()`, `descendants()`,
`prevNodes`, `nextNodes`
* #50: implemented tree rebuilding feature

### 3.1.1

Expand Down
30 changes: 29 additions & 1 deletion README.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,34 @@ $node = Category::create([

`$node->children` now contains a list of created child nodes.

#### Rebuilding a tree from array

You can easily rebuild a tree. This is useful for mass-changing the structure of
the tree.

```php
Category::rebuildTree($data, $delete);
```

`$data` is an array of nodes:

```php
$data = [
[ 'id' => 1, 'name' => 'foo', 'children' => [ ... ] ],
[ 'name' => 'bar' ],
];
```

There is an id specified for node with the name of `foo` which means that existing
node will be filled and saved. If node is not exists `ModelNotFoundException` is
thrown. Also, this node has `children` specified which is also an array of nodes;
they will be processed in the same manner and saved as children of node `foo`.

Node `bar` has no primary key specified, so it will be created.

`$delete` shows whether to delete nodes that are already exists but not present
in `$data`. By default, nodes aren't deleted.

### Retrieving nodes

*In some cases we will use an `$id` variable which is an id of the target node.*
Expand Down Expand Up @@ -504,7 +532,7 @@ MenuItem::scoped([ 'menu_id' => 5 ])->fixTree();
```

When requesting nodes using model instance, scopes applied automatically based
on data of that model. See examples:
on the attributes of that model. See examples:

```php
$node = MenuItem::findOrFail($id);
Expand Down
8 changes: 4 additions & 4 deletions src/Collection.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,11 @@ public function linkNodes()
*
* If `$root` is provided, the tree will contain only descendants of that node.
*
* @param int|Model|null $root
* @param mixed $root
*
* @return Collection
*/
public function toTree($root = null)
public function toTree($root = false)
{
if ($this->isEmpty()) {
return new static;
Expand All @@ -77,13 +77,13 @@ public function toTree($root = null)
*
* @return int
*/
protected function getRootNodeId($root = null)
protected function getRootNodeId($root)
{
if (NestedSet::isNode($root)) {
return $root->getKey();
}

if ($root !== null) {
if ($root !== false) {
return $root;
}

Expand Down
30 changes: 26 additions & 4 deletions src/NodeTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ public static function bootNodeTrait()
*
* @return $this
*/
protected function setAction($action)
protected function setNodeAction($action)
{
$this->pending = func_get_args();

Expand Down Expand Up @@ -136,6 +136,14 @@ public static function usesSoftDelete()
return $softDelete;
}

/**
* @return bool
*/
protected function actionRaw()
{
return true;
}

/**
* Make a root node.
*/
Expand Down Expand Up @@ -342,7 +350,7 @@ public function ancestors()
*/
public function makeRoot()
{
return $this->setAction('root');
return $this->setNodeAction('root');
}

/**
Expand Down Expand Up @@ -420,7 +428,7 @@ public function appendOrPrependTo(self $parent, $prepend = false)

$this->setParent($parent)->dirtyBounds();

return $this->setAction('appendOrPrepend', $parent, $prepend);
return $this->setNodeAction('appendOrPrepend', $parent, $prepend);
}

/**
Expand Down Expand Up @@ -463,7 +471,7 @@ public function beforeOrAfterNode(self $node, $after = false)

$this->dirtyBounds();

return $this->setAction('beforeOrAfter', $node, $after);
return $this->setNodeAction('beforeOrAfter', $node, $after);
}

/**
Expand Down Expand Up @@ -495,6 +503,20 @@ public function insertBeforeNode(self $node)
return true;
}

/**
* @param $lft
* @param $rgt
* @param $parentId
*
* @return $this
*/
public function rawNode($lft, $rgt, $parentId)
{
$this->setLft($lft)->setRgt($rgt)->setParentId($parentId);

return $this->setNodeAction('raw');
}

/**
* Move node up given amount of positions.
*
Expand Down
139 changes: 113 additions & 26 deletions src/QueryBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Query\Builder as Query;
use Illuminate\Database\Query\Builder as BaseQueryBuilder;
use Illuminate\Support\Arr;
use LogicException;
use Illuminate\Database\Query\Expression;

Expand Down Expand Up @@ -726,9 +727,9 @@ public function isBroken()
/**
* Fixes the tree based on parentage info.
*
* Requires at least one root node. This will not update nodes with invalid parent.
* Nodes with invalid parent are saved as roots.
*
* @return int The number of fixed nodes.
* @return int The number of fixed nodes
*/
public function fixTree()
{
Expand All @@ -739,54 +740,64 @@ public function fixTree()
$this->model->getRgtName(),
];

$nodes = $this->model
->newNestedSetQuery()
->defaultOrder()
->get($columns)
->groupBy($this->model->getParentIdName());
$dictionary = $this->defaultOrder()
->get($columns)
->groupBy($this->model->getParentIdName())
->all();

return self::fixNodes($dictionary);
}

/**
* @param array $dictionary
*
* @return int
*/
protected static function fixNodes(array &$dictionary)
{
$fixed = 0;

$cut = self::reorderNodes($nodes, $fixed);
$cut = self::reorderNodes($dictionary, $fixed);

// Saved nodes that have invalid parent as roots
while ( ! $nodes->isEmpty()) {
$parentId = $nodes->keys()->first();
// Save nodes that have invalid parent as roots
while ( ! empty($dictionary)) {
$dictionary[null] = reset($dictionary);

foreach ($nodes[$parentId] as $model) {
$model->setParentId(null);
}
unset($dictionary[key($dictionary)]);

$cut = self::reorderNodes($nodes, $fixed, $parentId, $cut);
$cut = self::reorderNodes($dictionary, $fixed, null, $cut);
}

return $fixed;
}

/**
* @param Collection $models
* @param array $dictionary
* @param int $fixed
* @param $parentId
* @param int $cut
*
* @return int
*/
protected static function reorderNodes(Collection $models, &$fixed,
protected static function reorderNodes(array &$dictionary, &$fixed,
$parentId = null, $cut = 1
) {
if ( ! isset($models[$parentId])) {
if ( ! isset($dictionary[$parentId])) {
return $cut;
}

/** @var Model|self $model */
foreach ($models[$parentId] as $model) {
$model->setLft($cut);
/** @var Model|NodeTrait $model */
foreach ($dictionary[$parentId] as $model) {
$lft = $cut;

$cut = self::reorderNodes($models, $fixed, $model->getKey(), $cut + 1);
$cut = self::reorderNodes($dictionary,
$fixed,
$model->getKey(),
$cut + 1);

$model->setRgt($cut);
$rgt = $cut;

if ($model->isDirty()) {
if ($model->rawNode($lft, $rgt, $parentId)->isDirty()) {
$model->save();

$fixed++;
Expand All @@ -795,13 +806,89 @@ protected static function reorderNodes(Collection $models, &$fixed,
++$cut;
}

unset($models[$parentId]);
unset($dictionary[$parentId]);

return $cut;
}

/**
* @param null $table
* Rebuild the tree based on raw data.
*
* If item data does not contain primary key, new node will be created.
*
* @param array $data
* @param bool $delete Whether to delete nodes that exists but not in the data
* array
*
* @return int
*/
public function rebuildTree(array $data, $delete = false)
{
$existing = $this->get()->getDictionary();
$dictionary = [];

$this->buildRebuildDictionary($dictionary, $data, $existing);

if ( ! empty($existing)) {
if ($delete) {
$this->model
->newScopedQuery()
->whereIn($this->model->getKeyName(), array_keys($existing))
->forceDelete();
} else {
foreach ($existing as $model) {
$dictionary[$model->getParentId()][] = $model;
}
}
}

return $this->fixNodes($dictionary);
}

/**
* @param array $dictionary
* @param array $data
* @param array $existing
* @param mixed $parentId
*/
protected function buildRebuildDictionary(array &$dictionary,
array $data,
array &$existing,
$parentId = null
) {
$keyName = $this->model->getKeyName();

foreach ($data as $itemData) {
if ( ! isset($itemData[$keyName])) {
$model = $this->model->newInstance();

// We will save it as raw node since tree will be fixed
$model->rawNode(0, 0, $parentId);
} else {
if ( ! isset($existing[$key = $itemData[$keyName]])) {
throw new ModelNotFoundException;
}

$model = $existing[$key];

unset($existing[$key]);
}

$model->fill($itemData)->save();

$dictionary[$parentId][] = $model;

if ( ! isset($itemData['children'])) continue;

$this->buildRebuildDictionary($dictionary,
$itemData['children'],
$existing,
$model->getKey());
}
}

/**
* @param string|null $table
*
* @return $this
*/
Expand Down
Loading

0 comments on commit d2a95b3

Please sign in to comment.