Outputs¶
An output consumes the node stream and builds the result. Calling one runs the whole pipeline. Outputs are
implemented as sinks; Tree has a method for each built-in one, and into($sink) takes any Sink.
Tree method |
Sink | Result | Memory |
|---|---|---|---|
iterate(), foreach ($tree …) |
— | Generator<int depth, mixed node> |
O(1) beyond the pipeline |
toNestedArray() |
NestedArraySink |
list of roots with children | O(output) |
toFlatList() |
FlatListSink |
list<array{depth: int, node: mixed}> |
O(output) |
toAdjacencyList() |
AdjacencyListSink |
pre-order list with rewritten parent ids | O(output) |
toNestedSet() |
NestedSetSink |
pre-order list with recomputed lft / rgt / level |
O(output) |
count() |
CountSink |
int |
O(1) |
into($sink) |
any Sink |
whatever the sink returns | — |
cursor() |
— | the raw Cursor, for code that drives it itself |
— |
None of them modify your payloads. Array rows are copied when a key is added; objects are wrapped or passed to your node factory.
Nested arrays¶
toNestedArray() returns a list of roots, each with its children.
- Array payloads get a children key:
['id' => 1, …, 'children' => [...]]. - Object payloads are wrapped in a
TreeNode(entities are never modified). - A
nodeFactorydecides the shape itself.
use Ghispi\TreeTransform\Tree;
require __DIR__ . '/../vendor/autoload.php';
$rows = [
['id' => 1, 'title' => 'Clothing', 'lft' => 1, 'rgt' => 6],
['id' => 2, 'title' => 'Men', 'lft' => 2, 'rgt' => 3],
['id' => 3, 'title' => 'Women', 'lft' => 4, 'rgt' => 5],
];
$tree = Tree::fromNestedSet($rows)->map(fn(array $row) => ['id' => $row['id'], 'title' => $row['title']]);
echo "Default: every node gets a 'children' key\n";
echo json_encode($tree->toNestedArray()), "\n\n";
echo "omitEmptyChildren: true\n";
echo json_encode($tree->toNestedArray(omitEmptyChildren: true)), "\n\n";
echo "childrenField: '__children' (same shape as Gedmo buildTree())\n";
echo json_encode($tree->toNestedArray(childrenField: '__children')), "\n\n";
echo "nodeFactory: build any shape\n";
echo json_encode($tree->toNestedArray(nodeFactory: fn(array $node, array $children, int $depth) => [
'text' => $node['title'],
'level' => $depth,
'nodes' => $children,
])), "\n";
Output:
Default: every node gets a 'children' key
[{"id":1,"title":"Clothing","children":[{"id":2,"title":"Men","children":[]},{"id":3,"title":"Women","children":[]}]}]
omitEmptyChildren: true
[{"id":1,"title":"Clothing","children":[{"id":2,"title":"Men"},{"id":3,"title":"Women"}]}]
childrenField: '__children' (same shape as Gedmo buildTree())
[{"id":1,"title":"Clothing","__children":[{"id":2,"title":"Men","__children":[]},{"id":3,"title":"Women","__children":[]}]}]
nodeFactory: build any shape
[{"text":"Clothing","level":0,"nodes":[{"text":"Men","level":1,"nodes":[]},{"text":"Women","level":1,"nodes":[]}]}]
Source: examples/outputs-nested-array.php
| Option | Type | Default | Effect |
|---|---|---|---|
childrenField |
string |
'children' |
Key for the children of array payloads. '__children' matches Gedmo's buildTree() |
omitEmptyChildren |
bool |
false |
Leave the children key out of leaves |
nodeFactory |
?Closure(mixed $node, list<mixed> $children, int $depth): mixed |
null |
Builds each node. $children holds already-built children |
The result is built bottom-up without recursion or PHP references. Memory: the size of the result.
Objects and TreeNode¶
Objects cannot get a children key, and the library does not modify them. Each one is wrapped in a readonly
TreeNode with public node (the original object) and children (a list of TreeNode).
use Ghispi\TreeTransform\Sink\TreeNode;
use Ghispi\TreeTransform\Tree;
require __DIR__ . '/../vendor/autoload.php';
final readonly class Page
{
public function __construct(
public string $title,
public string $path,
) {}
}
$pages = [
new Page('About', '/about'),
new Page('Team', '/about/team'),
new Page('Jobs', '/about/jobs'),
new Page('Blog', '/blog'),
];
// Objects are never modified: each one is wrapped in a TreeNode with its children.
$roots = Tree::fromMaterializedPath($pages, new Ghispi\TreeTransform\Source\MaterializedPathFields(separator: '/'))
->toNestedArray();
foreach ($roots as $root) {
assert($root instanceof TreeNode);
echo $root->node->title, ' (', count($root), ' children, leaf: ', var_export($root->isLeaf(), true), ")\n";
foreach ($root as $child) { // iterates the direct children
assert($child instanceof TreeNode);
echo ' ', $child->node->title, "\n";
}
}
Output:
Source: examples/outputs-objects.php
TreeNode is Countable (number of direct children) and iterable (the direct children), and has isLeaf(). For a
different shape (a DTO, a view model), pass a nodeFactory, or map() objects to arrays first.
iterate¶
iterate() streams nodes in pre-order as depth => node. Nothing is collected, so it works for any tree size. A
Tree is itself iterable and does the same.
use Ghispi\TreeTransform\Tree;
require __DIR__ . '/../vendor/autoload.php';
$rows = require __DIR__ . '/support/shop.php';
$tree = Tree::fromNestedSet($rows)->maxDepth(2);
// Keys are depths and repeat, so loop with foreach. iterator_to_array() would keep only one node per depth.
foreach ($tree->iterate() as $depth => $category) {
echo str_repeat(' ', $depth), $category['title'], "\n";
}
// A Tree is iterable itself: foreach ($tree as $depth => $node) does the same.
echo "\nNodes: ", $tree->count(), "\n";
Output:
Source: examples/outputs-iterate.php
Gotcha: keys are depths, so they repeat. iterator_to_array($tree->iterate()) keeps one node per depth. Use
iterator_to_array($tree->iterate(), false) for a plain list of nodes, or toFlatList() to keep the depths.
To skip a subtree while iterating, drive the generator yourself and call send(true) instead of next(): it moves
past the current node's descendants, and the source does not read them further (like skipSubtree() on a cursor).
use Ghispi\TreeTransform\Tree;
require __DIR__ . '/../vendor/autoload.php';
$rows = require __DIR__ . '/support/shop.php';
// A sitemap that does not descend into Electronics: send(true) instead of next() skips the current node's subtree.
// The source skips those rows without reading them further.
$nodes = Tree::fromNestedSet($rows)->iterate();
while ($nodes->valid()) {
$depth = $nodes->key();
$category = $nodes->current();
echo str_repeat(' ', $depth), $category['title'], "\n";
if ($category['title'] === 'Electronics') {
$nodes->send(true);
} else {
$nodes->next();
}
}
Output:
Source: examples/outputs-iterate-skip.php
Do not mix this with foreach: foreach calls next() on its own, so a send(true) inside it advances twice and
drops the node after the skipped subtree. Start the generator (valid(), current() or key()) before the first
send(), as the loop above does; on an unstarted generator PHP first runs to the first node and then skips it.
The output of iterate() can feed another pipeline: Tree::fromDepthList($tree->iterate()). See
Sources → Depth lists.
Flat list¶
toFlatList() collects the same pre-order entries into an array of ['depth' => int, 'node' => mixed], e.g. for
an indented <select> rendered by a template. Tree::fromFlatList() reads it back.
use Ghispi\TreeTransform\Tree;
require __DIR__ . '/../vendor/autoload.php';
$rows = require __DIR__ . '/support/shop.php';
$list = Tree::fromNestedSet($rows)
->maxDepth(1)
->map(fn(array $c) => $c['title'])
->toFlatList();
var_export($list);
echo "\n";
// Tree::fromFlatList() reads the same shape back.
echo implode(' / ', iterator_to_array(Tree::fromFlatList($list)->depthWindow(1)->iterate(), false)), "\n";
Output:
array (
0 =>
array (
'depth' => 0,
'node' => 'Shop',
),
1 =>
array (
'depth' => 1,
'node' => 'Clothing',
),
2 =>
array (
'depth' => 1,
'node' => 'Electronics',
),
3 =>
array (
'depth' => 1,
'node' => 'Garden',
),
)
Clothing / Electronics / Garden
Source: examples/outputs-flat-list.php
Adjacency list¶
toAdjacencyList() returns a pre-order list where each node's parent field points to its parent in the
output. After Promote or depthWindow() that differs from the
input.
use Ghispi\TreeTransform\Source\AdjacencyListFields;
use Ghispi\TreeTransform\Strategy\Promote;
use Ghispi\TreeTransform\Tree;
require __DIR__ . '/../vendor/autoload.php';
$rows = require __DIR__ . '/support/shop.php';
// Only categories with products, moved up to the nearest kept ancestor: parents are rewritten.
$list = Tree::fromNestedSet($rows)
->filter(fn(array $c) => $c['products'] > 0, new Promote())
->map(fn(array $c) => ['id' => $c['id'], 'title' => $c['title']])
->toAdjacencyList(new AdjacencyListFields('id', 'parent_id'), rootParent: 0);
foreach ($list as $row) {
printf("%-3d %-8s parent_id: %d\n", $row['id'], $row['title'], $row['parent_id']);
}
// Objects cannot get a parent field set; build the row with a factory instead.
$pairs = Tree::fromNestedSet($rows)
->maxDepth(1)
->toAdjacencyList(nodeFactory: fn(array $c, int|string|null $parentId, int $depth) => "{$c['id']}->" . ($parentId ?? 'null'));
echo "\n", implode(', ', $pairs), "\n";
Output:
3 Men parent_id: 0
4 Shirts parent_id: 3
10 Cases parent_id: 0
11 Laptops parent_id: 0
1->null, 2->1, 8->1, 12->1
Source: examples/outputs-adjacency-list.php
| Option | Type | Default | Effect |
|---|---|---|---|
fields |
AdjacencyListFields |
id, parent_id |
The id is read from id, the parent id written to parent (array payloads) — the same object fromAdjacencyList() takes |
accessor |
Accessor |
AutoAccessor |
Reads the id |
rootParent |
mixed |
null |
Parent value written for roots, e.g. 0 |
nodeFactory |
?Closure(mixed $node, int\|string\|null $parentId, int $depth): mixed |
null |
Builds each output row. Required for object payloads |
Object payloads without a nodeFactory throw InvalidArgumentException.
Nested set¶
toNestedSet() returns a pre-order list with recomputed left, right and level values — to store a filtered or
restructured tree, or to hand it to code that expects nested sets.
use Ghispi\TreeTransform\Source\NestedSetFields;
use Ghispi\TreeTransform\Tree;
require __DIR__ . '/../vendor/autoload.php';
$rows = require __DIR__ . '/support/shop.php';
// Drop the Electronics branch and renumber: left/right/level are recomputed for the result.
$rows = Tree::fromNestedSet($rows)
->filter(fn(array $c) => $c['slug'] !== 'electronics')
->maxDepth(1)
->map(fn(array $c) => ['id' => $c['id'], 'title' => $c['title']])
->toNestedSet(new NestedSetFields(left: 'lft', right: 'rgt', level: 'lvl'), levelBase: 0);
foreach ($rows as $row) {
printf("%-2d %-9s lft %2d rgt %2d lvl %d\n", $row['id'], $row['title'], $row['lft'], $row['rgt'], $row['lvl']);
}
// Several roots numbered from 1 each, with the id of each node's new root (Gedmo with a TreeRoot column).
$forest = Tree::fromNestedSet(require __DIR__ . '/support/shop.php')
->depthWindow(1, 1)
->map(fn(array $c) => ['id' => $c['id'], 'title' => $c['title']])
->toNestedSet(new NestedSetFields(root: 'root'));
echo "\n";
foreach ($forest as $row) {
echo "{$row['title']}: {$row['lft']}..{$row['rgt']} root {$row['root']}\n";
}
Output:
1 Shop lft 1 rgt 6 lvl 0
2 Clothing lft 2 rgt 3 lvl 1
12 Garden lft 4 rgt 5 lvl 1
Clothing: 1..2 root 2
Electronics: 1..2 root 8
Garden: 1..2 root 12
Source: examples/outputs-nested-set.php
| Option | Type | Default | Effect |
|---|---|---|---|
fields |
NestedSetFields |
lft, rgt, level lvl, no root |
Fields written — the same object fromNestedSet() takes. level: null leaves the level out. With a root field every root's tree is numbered from 1 and each node gets the id of its root in the output (Gedmo with a TreeRoot column); NestedSetFields::gedmo() sets lft, rgt, root, lvl |
levelBase |
int |
0 |
Level of roots (Gedmo uses 0; some schemas 1) |
nodeFactory |
?Closure(mixed $node, int $left, int $right, int $depth, mixed $rootId): mixed |
null |
Builds each output row. Required for object payloads. $rootId is null without a root field |
idField |
string |
'id' |
Field the root id is read from (only with a root field) |
accessor |
Accessor |
AutoAccessor |
Reads the root id |
The root ids are those of the output roots: after depthWindow() or Promote, nodes move to new roots, and a
round trip through NestedSetFields::gedmo() stays valid. Every root needs an int or string id in idField (two
consecutive roots with the same id, or a root without one, throw InvalidArgumentException): set idField when
your id column is not id. A column that groups several roots, such as a menu id with one numbering per menu, is
not a root field for the sink — leave root out and keep the column with map().
The right value of a node is known only when its subtree ends, so the whole result is collected first. Memory: O(output).
count¶
count() returns the number of nodes in the result without collecting them.
Your own sink¶
into() accepts any Sink, for example one that renders HTML in a single pass: see
Extending → Sink.