diff --git a/readme.md b/readme.md index 84db4bb..254c7c4 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,10 @@ # Oliva -Flexible tree structures, materialized path trees, tree traversal iterators. +Flexible tree structure, +materialized path trees, +recursive trees and builders, +tree traversal iterators, +filter iterator. > > 💿 `composer require dakujem/oliva` @@ -118,15 +122,17 @@ $collection = [ new Item(id: 7, path: '000001'), ]; -$builder = new TreeBuilder(); -$root = $builder->build( - input: $collection, +$builder = new TreeBuilder( node: fn(Item $item) => new Node($item), // How to create a node. vector: TreeBuilder::fixed( // How to extract path vector. levelWidth: 3, accessor: fn(Item $item) => $item->path, ), ); + +$root = $builder->build( + input: $collection, +); ``` Same example with an equivalent delimited MPT: @@ -146,15 +152,17 @@ $collection = [ new Item(id: 7, path: '.0.1'), ]; -$builder = new TreeBuilder(); -$root = $builder->build( - input: $collection, +$builder = new TreeBuilder( node: fn(Item $item) => new Node($item), // How to create a node. vector: TreeBuilder::delimited( // How to extract path vector. delimiter: '.', accessor: fn(Item $item) => $item->path, ), ); + +$root = $builder->build( + input: $collection, +); ``` > @@ -189,14 +197,16 @@ $collection = [ new Item(id: 7, parent: 1), ]; -$builder = new TreeBuilder(); -$root = $builder->build( - input: $collection, +$builder = new TreeBuilder( node: fn(Item $item) => new Node($item), // How to create a node. self: fn(Item $item) => $item->id, // How to get ID of self. parent: fn(Item $item) => $item->parent, // How to get parent ID. root: null, // The root node's parent value. ); + +$root = $builder->build( + input: $collection, +); ``` Above, `self` and `parent` parameters expect extractors with signature @@ -243,11 +253,12 @@ $rawData = [ $builder = new TreeWrapper( node: function(array $item) { // How to create a node. - unset($item['children']); + unset($item['children']); // Note the unset call optimization. return new Node($item); }, children: fn(array $item):array => $item['children'] ?? [], // How to extract children. ); + $root = $builder->wrap($rawData); ``` @@ -263,8 +274,7 @@ Above, `children` expects an extractor with signature `fn(mixed $data, TreeNodeC ## Manual tree building - -Using a manual builder: +Using a manual node builder: ```php use Dakujem\Oliva\Node; use Dakujem\Oliva\Simple\NodeBuilder; @@ -299,7 +309,8 @@ $leaf2 = new Node('another leaf of the first child node'); $child1->addChild($leaf2); $leaf2->setParent($child1); ``` -... yeah, this is not the most optimal way to build a tree. + +... yeah, that is not the most optimal way to build a tree. Using high-level manipulator (`Tree`) for doing the same: ```php @@ -407,11 +418,11 @@ It is possible to alter the key sequence using a key callable. This example generates a delimited materialized path: ```php use Dakujem\Oliva\Iterator\PreOrderTraversal; -use Dakujem\Oliva\TreeNodeContract; +use Dakujem\Oliva\Node; $iterator = new PreOrderTraversal( node: $root, - key: fn(TreeNodeContract $node, array $vector, int $seq, int $counter): string => '.' . implode('.', $vector), + key: fn(Node $node, array $vector): string => '.' . implode('.', $vector), startingVector: [], ); $result = iterator_to_array($iterator); @@ -431,14 +442,24 @@ $result = iterator_to_array($iterator); This example indexes the nodes by an ID found in the data: ```php use Dakujem\Oliva\Iterator\PreOrderTraversal; +use Dakujem\Oliva\Node; $iterator = new PreOrderTraversal( node: $root, - key: fn(TreeNodeContract $node): int => $node->data()->id, + key: fn(Node $node): int => $node->data()->id, ); ``` -The signature of the key callable is `fn(TreeNodeContract $node, array $vector, int $seq, int $counter): string|int`, where +The full signature of the key callable is +```php +fn( + Dakujem\Oliva\TreeNodeContract $node, + array $vector, // array + int $seq, + int $counter +): string|int +``` +where - `$node` is the current node - `$vector` is the node's vector in a tree - it is a path from the root to the node with **child indexes** being the vector's elements @@ -466,10 +487,11 @@ All Oliva traversal iterators accept a key callable and a starting vector (a pre > It may be useful for others too, it provides solutions to real-world scenarios. > -### MPT builder +### Materialized path tree without root data -There may be situations where the source data does not contain a root. -This may be done when storing article comments, menus or forum posts and consider the parent object (the article, the thread or the site) to be the root. +There may be situations where the source data does not contain a root. +It may be a result of storing article comments, menus or forum posts +and considering the parent object (the article, the thread or the site) to be the root. One of the solutions is to prepend an empty data element and then ignore it during iterations if it is not desired to iterate over the root. @@ -490,12 +512,12 @@ $root = $builder->build( input: Seed::nullFirst($source), // `null` is prepended to the data ); -foreach(Seed::omitNull($root) as $node) { // the node with `null` data is omitted from the iteration +foreach(Seed::omitNull($root) as $node) { // The node with `null` data is omitted from the iteration display($node); } ``` -We could also use `Seed::merged` to prepend an item with fabricated root data, but then `Seed::omitRoot` must be used omit the root instead: +We could also use `Seed::merged` to prepend an item with fabricated root data, but then `Seed::omitRoot` must be used to omit the root instead: ```php use Dakujem\Oliva\MaterializedPath; use Dakujem\Oliva\Seed; @@ -511,12 +533,12 @@ $root = $builder->build( input: Seed::merged([new Item(id: 0, path: '')], $source), ); -foreach(Seed::omitRoot($root) as $node) { // the root node is omitted from the iteration +foreach(Seed::omitRoot($root) as $node) { // The root node is omitted from the iteration display($node); } ``` -### Recursive builder +### Recursive tree without root data Similar situation may happen when using the recursive builder on a subtree, when the root node of the subtree has a non-null parent. @@ -528,7 +550,7 @@ use Dakujem\Oliva\Recursive\TreeBuilder; use Dakujem\Oliva\Node; $collection = [ - new Item(id: 100, parent: 99), // Note that no data with ID 99 is present. + new Item(id: 100, parent: 99), // Note that no data with ID 99 is present new Item(id: 101, parent: 100), new Item(id: 102, parent: 100), new Item(id: 103, parent: 100), @@ -538,13 +560,15 @@ $collection = [ new Item(id: 107, parent: 101), ]; -$builder = new TreeBuilder(); -$root = $builder->build( - input: $collection, +$builder = new TreeBuilder( node: fn(Item $item) => new Node($item), self: fn(Item $item) => $item->id, parent: fn(Item $item) => $item->parent, - root: 99, // Here we indicate that the parent of the root node is 99. + root: 99, // Here we indicate what the parent of the root is +); + +$root = $builder->build( + input: $collection, ); ``` @@ -562,6 +586,6 @@ composer test ## Contributing -Ideas or contribution is welcome. Please send a PR or file an issue. +Ideas or contribution is welcome. Please send a PR or submit an issue. And if you happen to like the library, spread the word 🙏. diff --git a/src/MaterializedPath/TreeBuilder.php b/src/MaterializedPath/TreeBuilder.php index 4ec4fb0..134820d 100644 --- a/src/MaterializedPath/TreeBuilder.php +++ b/src/MaterializedPath/TreeBuilder.php @@ -4,9 +4,9 @@ namespace Dakujem\Oliva\MaterializedPath; +use Dakujem\Oliva\MaterializedPath\Support\AlmostThere; use Dakujem\Oliva\MaterializedPath\Support\Register; use Dakujem\Oliva\MaterializedPath\Support\ShadowNode; -use Dakujem\Oliva\MaterializedPath\Support\AlmostThere; use Dakujem\Oliva\MovableNodeContract; use Dakujem\Oliva\TreeNodeContract; use LogicException; @@ -25,26 +25,48 @@ * * Fixed path variant example: * ``` - * $root = (new TreeBuilder())->build( - * $myItemCollection, + * $builder = new TreeBuilder( * fn(MyItem $item) => new Node($item), * TreeBuilder::fixed(3, fn(MyItem $item) => $item->path), * ); + * $root = $builder->build( $myItemCollection ); * ``` * * Delimited path variant example: * ``` - * $root = (new TreeBuilder())->build( - * $myItemCollection, + * $builder = new TreeBuilder( * fn(MyItem $item) => new Node($item), * TreeBuilder::delimited('.', fn(MyItem $item) => $item->path), * ); + * $root = $builder->build( $myItemCollection ); * ``` * * @author Andrej Rypak */ final class TreeBuilder { + /** + * Node factory, + * signature `fn(mixed $data): MovableNodeContract`. + * @var callable + */ + private $node; + + /** + * Extractor of the node vector, + * signature `fn(mixed $data, mixed $inputIndex, TreeNodeContract $node): array`. + * @var callable + */ + private $vector; + + public function __construct( + callable $node, + callable $vector, + ) { + $this->node = $node; + $this->vector = $vector; + } + public static function fixed(int $levelWidth, callable $accessor): callable { return function (mixed $data) use ($levelWidth, $accessor): array { @@ -82,27 +104,21 @@ public static function delimited(string $delimiter, callable $accessor): callabl }; } - public function build( - iterable $input, - callable $node, - callable $vector, - ): TreeNodeContract { - $root = $this->processInput($input, $node, $vector)->root(); + public function build(iterable $input): TreeNodeContract + { + $root = $this->processInput($input)->root(); if (null === $root) { throw new RuntimeException('Corrupted input, no tree created.'); } return $root; } - public function processInput( - iterable $input, - callable $node, - callable $vector, - ): AlmostThere { + public function processInput(iterable $input): AlmostThere + { $shadowRoot = $this->buildShadowTree( $input, - $node, - $vector, + $this->node, + $this->vector, ); // The actual tree nodes are not yet connected. diff --git a/src/Recursive/TreeBuilder.php b/src/Recursive/TreeBuilder.php index b03940c..87734df 100644 --- a/src/Recursive/TreeBuilder.php +++ b/src/Recursive/TreeBuilder.php @@ -6,6 +6,7 @@ use Dakujem\Oliva\MovableNodeContract; use Dakujem\Oliva\TreeNodeContract; +use InvalidArgumentException; use LogicException; /** @@ -15,41 +16,75 @@ * * Example for collections containing items with `id` and `parent` props, the root being the node with `null` parent: * ``` - * $root = (new TreeBuilder())->build( - * $myItemCollection, + * $builder = new TreeBuilder( * fn(MyItem $item) => new Node($item), - * TreeBuilder::prop('id'), - * TreeBuilder::prop('parent'), + * fn(MyItem $item) => $item->id, + * fn(MyItem $item) => $item->parent, * ); + * $root = $builder->build( $myItemCollection ); * ``` * * @author Andrej Rypak */ final class TreeBuilder { - public static function prop(string $name): callable - { - return fn(object $item) => $item->{$name} ?? null; - } + /** + * Node factory, + * signature `fn(mixed $data): MovableNodeContract`. + * @var callable + */ + private $node; - public static function attr(string $name): callable - { - return fn(array $item) => $item[$name] ?? null; - } + /** + * Extractor of the self reference, + * signature `fn(mixed $data, mixed $inputIndex, TreeNodeContract $node): string|int`. + * @var callable + */ + private $self; - public function build( - iterable $input, + /** + * Extractor of the parent reference, + * signature `fn(mixed $data, mixed $inputIndex, TreeNodeContract $node): string|int|null`. + * @var callable + */ + private $parent; + + /** + * Callable that detects the root node. + * Signature `fn(mixed $data, mixed $inputIndex, TreeNodeContract $node, string|int|null $parentRef, string|int $selfRef): bool`. + * @var callable + */ + private $root; + + public function __construct( callable $node, callable $self, callable $parent, - string|int|null $root = null, - ): TreeNodeContract { + string|int|callable|null $root = null, + ) { + $this->node = $node; + $this->self = $self; + $this->parent = $parent; + if (null === $root || is_string($root) || is_int($root)) { + // By default, the root node is detected by having the parent ref equal to `null`. + // By passing in a string or an integer, the root node will be detected by comparing that value to the node's parent value. + // For custom "is root" detector, use a callable. + $this->root = fn($data, $inputIndex, $node, $parentRef, $selfRef): bool => $parentRef === $root; + } elseif (is_callable($root)) { + $this->root = $root; + } else { + throw new InvalidArgumentException(); + } + } + + public function build(iterable $input): TreeNodeContract + { [$root] = $this->processData( input: $input, - nodeFactory: $node, - selfRefExtractor: $self, - parentRefExtractor: $parent, - rootRef: $root, + nodeFactory: $this->node, + selfRefExtractor: $this->self, + parentRefExtractor: $this->parent, + isRoot: $this->root, ); return $root; } @@ -59,13 +94,23 @@ private function processData( callable $nodeFactory, callable $selfRefExtractor, callable $parentRefExtractor, - string|int|null $rootRef = null, + callable $isRoot, ): array { + // + // This algo works in two passes. + // + // The first pass indexes the data and builds a map of nodes and their children. + // The second pass recursively connects all that indexed data starting from the root node. + // + /** @var array> $childRegister */ $childRegister = []; /** @var array $nodeRegister */ $nodeRegister = []; -// $root = null; + $rootFound = false; + $rootRef = null; + + // The data indexing pass. foreach ($input as $inputIndex => $data) { // Create a node using the provided factory. $node = $nodeFactory($data, $inputIndex); @@ -85,8 +130,10 @@ private function processData( } $nodeRegister[$self] = $node; - // No parent, when this node is the root. - if ($rootRef === $self) { + // When this node is the root, it has no parent. + if (!$rootFound && $isRoot($data, $inputIndex, $node, $parent, $self)) { + $rootRef = $self; + $rootFound = true; continue; } @@ -95,6 +142,13 @@ private function processData( } $childRegister[$parent][] = $self; } + + if (!$rootFound) { + // TODO improve exceptions + throw new LogicException('No root node found.'); + } + + // The tree reconstruction pass. $this->connectNode( $nodeRegister, $childRegister, @@ -112,8 +166,11 @@ private function processData( * @param array $nodeRegister * @param array> $childRegister */ - private function connectNode(array $nodeRegister, array $childRegister, string|int|null $ref): void - { + private function connectNode( + array $nodeRegister, + array $childRegister, + string|int|null $ref, + ): void { $parent = $nodeRegister[$ref]; foreach ($childRegister[$ref] ?? [] as $childRef) { $child = $nodeRegister[$childRef]; diff --git a/src/Seed.php b/src/Seed.php index aa72e16..1d71082 100644 --- a/src/Seed.php +++ b/src/Seed.php @@ -14,7 +14,8 @@ * * Contains: * - methods that produce or adapt iterable data and iterators - * - methods that produce filtering callables to be used with the Filter iterator + * - methods that produce filtering callables to be used with the Filter iterator + * - methods that produce extractor callables to be used with tree builders * * @author Andrej Rypak */ @@ -74,6 +75,24 @@ public static function omitRoot(): callable return fn(TreeNodeContract $node): bool => !$node->isRoot(); } + /** + * Create an extractor that extracts a property of objects. + * Note: Abbreviation of "property". + */ + public static function prop(string $name, mixed $default = null): callable + { + return fn(object $item) => $item->{$name} ?? $default; + } + + /** + * Create an extractor that extracts a member of arrays. + * Note: Abbreviation of "attribute". + */ + public static function attr(string|int $name, mixed $default = null): callable + { + return fn(array $item) => $item[$name] ?? $default; + } + /** * Accepts any iterable and returns an iterator. * Useful where en iterator is required, but any iterable or array is provided. diff --git a/tests/mptree.phpt b/tests/mptree.phpt index eed334e..268d5a6 100644 --- a/tests/mptree.phpt +++ b/tests/mptree.phpt @@ -35,15 +35,16 @@ $data = [ new Item(8, '008'), ]; -$builder = new TreeBuilder(); -$tree = $builder->processInput( - input: Seed::nullFirst($data), +$builder = new TreeBuilder( node: fn(?Item $item) => new Node($item), vector: TreeBuilder::fixed( 3, fn(?Item $item) => $item?->path, ), ); +$tree = $builder->processInput( + input: Seed::nullFirst($data), +); $it = new PreOrderTraversal($tree->root(), fn( TreeNodeContract $node, diff --git a/tests/recursive.phpt b/tests/recursive.phpt index 6e94a0e..c495072 100644 --- a/tests/recursive.phpt +++ b/tests/recursive.phpt @@ -31,14 +31,15 @@ $data = [ new Item(6, 5), ]; -$builder = new TreeBuilder(); - -$tree = $builder->build( - input: Seed::nullFirst($data), +$builder = new TreeBuilder( node: fn(?Item $item) => new Node($item), self: fn(?Item $item) => $item?->id, parent: fn(?Item $item) => $item?->parent, ); +$tree = $builder->build( + input: Seed::nullFirst($data), +); + Assert::type(Node::class, $tree);