Predicates¶
A predicate decides whether a node matches. What happens to matching and non-matching nodes is up to the strategy.
interface Predicate
{
public function matches(mixed $node, int $depth, Ancestors $ancestors): bool;
}
Everywhere a predicate is expected you can pass a Predicate object or a closure.
Closures¶
A closure can take one, two or three parameters: the node, its depth (0 for roots), and a lazy view of its ancestors. PHP ignores the arguments a closure does not declare.
use Ghispi\TreeTransform\Predicate\Ancestors;
use Ghispi\TreeTransform\Tree;
require __DIR__ . '/../vendor/autoload.php';
$printTree = require __DIR__ . '/support/print-tree.php';
$categories = [
['title' => 'Shop', 'active' => true, 'children' => [
['title' => 'Clothing', 'active' => true, 'children' => [
['title' => 'Men', 'active' => true, 'children' => [
['title' => 'Shirts', 'active' => true],
]],
['title' => 'Sale', 'active' => false],
]],
]],
];
$tree = Tree::fromNestedArray($categories);
$title = fn(array $c) => $c['title'];
// One parameter: the node.
echo $printTree($tree->filter(fn(array $c) => $c['active']), $title), "\n";
// Two parameters: the node and its depth (0 for roots).
echo $printTree($tree->filter(fn(array $c, int $depth) => $depth <= 2), $title), "\n";
// Three parameters: the node, its depth and a lazy view of its ancestors.
echo $printTree(
$tree->filter(fn(array $c, int $depth, Ancestors $ancestors) => !$ancestors->some(fn(array $a) => $a['title'] === 'Men')),
$title,
), "\n";
// The result is cast to bool, so database values such as "1" / "0" work as they are.
$rows = [['title' => 'Shop', 'active' => '1', 'children' => [['title' => 'Sale', 'active' => '0']]]];
echo $printTree(Tree::fromNestedArray($rows)->filter(fn(array $c) => $c['active']), $title);
Output:
Shop
└─ Clothing
└─ Men
└─ Shirts
Shop
└─ Clothing
├─ Men
└─ Sale
Shop
└─ Clothing
├─ Men
└─ Sale
Shop
Source: examples/predicates-closures.php
The result is cast to bool, so "1" / "0" from a database work as they are. Mind PHP's casting rules:
"0.0", "false" and " " are truthy.
Built-in predicates¶
| Predicate | Matches when | Notes |
|---|---|---|
new Callback($closure) |
the closure returns true | What closures are wrapped in. Callback::of($p) wraps a closure or returns a Predicate unchanged |
new FieldEquals($field, $value, ?$accessor) |
the field equals $value (===, but 5 and '5' are equal) |
Arrays and objects, via AutoAccessor by default |
new FieldIn($field, $values, ?$accessor) |
the field is one of $values (ints/strings) |
Hash lookup, O(1) per node. "5" matches 5, like array keys |
new DepthRange($min, $max) |
$min <= depth <= $max |
Roots are depth 0. $max defaults to PHP_INT_MAX |
new AllOf(...$predicates) |
every inner predicate matches (AND) | Short-circuits. Empty: always matches |
new AnyOf(...$predicates) |
at least one matches (OR) | Short-circuits. Empty: never matches |
new Not($predicate) |
the inner predicate does not match |
The combinators accept closures and Predicate objects mixed.
use Ghispi\TreeTransform\Predicate\AllOf;
use Ghispi\TreeTransform\Predicate\AnyOf;
use Ghispi\TreeTransform\Predicate\DepthRange;
use Ghispi\TreeTransform\Predicate\FieldEquals;
use Ghispi\TreeTransform\Predicate\FieldIn;
use Ghispi\TreeTransform\Predicate\Not;
use Ghispi\TreeTransform\Strategy\KeepAncestors;
use Ghispi\TreeTransform\Tree;
require __DIR__ . '/../vendor/autoload.php';
$printTree = require __DIR__ . '/support/print-tree.php';
$pages = [
['id' => 1, 'title' => 'Home', 'status' => 'published', 'children' => [
['id' => 2, 'title' => 'Products', 'status' => 'published', 'children' => [
['id' => 3, 'title' => 'Laptops', 'status' => 'draft'],
['id' => 4, 'title' => 'Phones', 'status' => 'published'],
]],
['id' => 5, 'title' => 'Blog', 'status' => 'published', 'children' => [
['id' => 6, 'title' => 'Old news', 'status' => 'archived'],
]],
['id' => 7, 'title' => 'Contact', 'status' => 'published'],
]],
];
$tree = Tree::fromNestedArray($pages);
$title = fn(array $page) => $page['title'];
echo "FieldEquals('status', 'published'):\n";
echo $printTree($tree->filter(new FieldEquals('status', 'published')), $title);
echo "\nFieldIn('id', [4, 6]) with KeepAncestors:\n";
echo $printTree($tree->filter(new FieldIn('id', [4, 6]), new KeepAncestors()), $title);
echo "\nAllOf(published, DepthRange(0, 1)):\n";
echo $printTree($tree->filter(new AllOf(new FieldEquals('status', 'published'), new DepthRange(0, 1))), $title);
echo "\nNot(FieldEquals('status', 'archived')):\n";
echo $printTree($tree->filter(new Not(new FieldEquals('status', 'archived'))), $title);
echo "\nAnyOf(closure, FieldEquals) with KeepAncestors:\n";
echo $printTree($tree->filter(
new AnyOf(fn(array $page) => str_starts_with($page['title'], 'Lap'), new FieldEquals('title', 'Contact')),
new KeepAncestors(),
), $title);
Output:
FieldEquals('status', 'published'):
Home
├─ Products
│ └─ Phones
├─ Blog
└─ Contact
FieldIn('id', [4, 6]) with KeepAncestors:
Home
├─ Products
│ └─ Phones
└─ Blog
└─ Old news
AllOf(published, DepthRange(0, 1)):
Home
├─ Products
├─ Blog
└─ Contact
Not(FieldEquals('status', 'archived')):
Home
├─ Products
│ ├─ Laptops
│ └─ Phones
├─ Blog
└─ Contact
AnyOf(closure, FieldEquals) with KeepAncestors:
Home
├─ Products
│ └─ Laptops
└─ Contact
Source: examples/predicates-combinators.php
FieldEquals compares with ===, except that integers and integer strings are equal: new FieldEquals('id', 5)
matches a row with 'id' => '5', as PDO often returns. FieldIn behaves the same way.
DepthRange with Prune cuts everything below the range — use maxDepth() for that, it reads
better. DepthRange is useful inside AllOf / AnyOf, or with KeepAncestors and Promote.
Ancestors¶
The third argument is an Ancestors view of the node's ancestors, root first. It is lazy: the filter keeps the
current path in a depth-indexed array it overwrites as it goes (one array write per node, O(depth) memory), and no
list is built unless you ask for one.
The filter only keeps the path when the predicate can read it: a closure that declares a third (or variadic)
parameter, a custom Predicate class, or a combinator containing one of those. Closures with one or two parameters
and the built-in field and depth predicates skip it, which saves 7–15% on large trees. So declare the parameter
instead of reading it with func_get_args().
use Ghispi\TreeTransform\Predicate\Ancestors;
use Ghispi\TreeTransform\Strategy\Promote;
use Ghispi\TreeTransform\Tree;
require __DIR__ . '/../vendor/autoload.php';
$printTree = require __DIR__ . '/support/print-tree.php';
$pages = [
['title' => 'Home', 'published' => true, 'children' => [
['title' => 'Blog', 'published' => true, 'children' => [
['title' => 'Drafts', 'published' => false, 'children' => [
['title' => 'Upcoming launch', 'published' => true],
]],
['title' => 'Hello world', 'published' => true],
]],
]],
];
$tree = Tree::fromNestedArray($pages);
// Pages under an unpublished section stay hidden, even if they are published themselves. Prune does this anyway;
// with Promote, "Upcoming launch" would otherwise move up under "Blog".
$visible = fn(array $page, int $depth, Ancestors $ancestors) => $page['published']
&& $ancestors->every(fn(array $parent) => $parent['published']);
echo $printTree($tree->filter($visible, new Promote()), fn(array $p) => $p['title']);
// Everything the view offers, for the "Upcoming launch" page.
$tree->filter(function (array $page, int $depth, Ancestors $ancestors): bool {
if ($page['title'] === 'Upcoming launch') {
echo "\ncount(): ", count($ancestors), " (= depth {$depth})\n";
echo 'parent(): ', $ancestors->parent()['title'], "\n";
echo 'at(0): ', $ancestors->at(0)['title'], "\n";
echo 'some(): ', var_export($ancestors->some(fn(array $p) => !$p['published']), true), "\n";
echo 'every(): ', var_export($ancestors->every(fn(array $p) => $p['published']), true), "\n";
echo 'toArray() titles: ', implode(' > ', array_column($ancestors->toArray(), 'title')), "\n";
echo 'foreach: ';
foreach ($ancestors as $ancestorDepth => $ancestor) {
echo "{$ancestorDepth}:{$ancestor['title']} ";
}
echo "\n";
}
return true;
})->count();
Output:
Home
└─ Blog
└─ Hello world
count(): 3 (= depth 3)
parent(): Drafts
at(0): Home
some(): true
every(): false
toArray() titles: Home > Blog > Drafts
foreach: 0:Home 1:Blog 2:Drafts
Source: examples/predicates-ancestors.php
| Method | Returns |
|---|---|
count($ancestors) |
Number of ancestors; equals the node's depth |
parent() |
The direct parent, or null for a root |
at(int $depth) |
The ancestor at that depth (0 = root). Throws Exception\OutOfRangeException outside 0 .. count() - 1 |
some(callable $test) |
True if an ancestor passes. Checks the nearest ancestor first, stops at the first hit |
every(callable $test) |
True if all ancestors pass (true for roots). Checks the nearest ancestor first |
toArray() |
A copy of the ancestors as a list, root first |
foreach ($ancestors as $depth => $ancestor) |
Iterates root first; keys are depths |
Gotchas:
- The view is reused for every node and is valid only during the predicate call. Do not store it; store
toArray()if you need the ancestors later. - Ancestors are those of the filter's input: the tree after earlier stages, not necessarily your raw rows. After
depthWindow(1), the old root is no longer an ancestor. - With
Promote, ancestors include the nodes that are being removed. That is what makes "hide everything under an unpublished section" work, as in the example above. - To test a predicate on its own, build a view with
Ancestors::of([$root, $parent])and call$predicate->matches($node, 2, $ancestors).
Writing your own¶
Implement Predicate when a check needs set-up (compile a regex, normalise a search term once) or is reused:
see Extending → Predicate.