Skip to content

Commit 3e19794

Browse files
committed
[Tree] Add pre-order and level-order traversals
1 parent 0c41b00 commit 3e19794

File tree

3 files changed

+237
-0
lines changed

3 files changed

+237
-0
lines changed

doc/tree.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1220,6 +1220,11 @@ There are repository methods that are available for you in all the strategies:
12201220
* childSort: array || keys allowed: field: field to sort on, dir: direction. 'asc' or 'desc'
12211221
- *includeNode*: Using "true", this argument allows you to include in the result the node you passed as the first argument. Defaults to "false".
12221222
* **setChildrenIndex** / **getChildrenIndex**: These methods allow you to change the default index used to hold the children when you use the **childrenHierarchy** method. Index defaults to "__children".
1223+
* **getNextNode** / **getNextNodes**: These methods allow you to get the next nodes when parsing a nested tree. Arguments:
1224+
- *root*: Root node use to select (sub-)tree to use.
1225+
- *node*: Current node. Use "null" to get the first one. Default to "null".
1226+
- *limit* (only for getNextNodes): Maximum nodes to return. Default to "null".
1227+
- *traversalStrategy*: Which strategy to use between "pre_order" and "level_order". Default to "NestedTreeRepository::TRAVERSAL_PRE_ORDER".
12231228

12241229
This list is not complete yet. We're working on including more methods in the common API offered by repositories of all the strategies.
12251230
Soon we'll be adding more helpful methods here.

src/Tree/Entity/Repository/NestedTreeRepository.php

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@
3636
*/
3737
class NestedTreeRepository extends AbstractTreeRepository
3838
{
39+
public const TRAVERSAL_PRE_ORDER = 'pre_order';
40+
public const TRAVERSAL_LEVEL_ORDER = 'level_order';
41+
3942
/**
4043
* Allows the following 'virtual' methods:
4144
* - persistAsFirstChild($node)
@@ -905,6 +908,100 @@ public function getNodesHierarchy($node = null, $direct = false, array $options
905908
return $this->getNodesHierarchyQuery($node, $direct, $options, $includeNode)->getArrayResult();
906909
}
907910

911+
/**
912+
* @param object $root Root node of the parsed tree
913+
* @param object|null $node Current node. If null, first node will be returned
914+
* @param int|null $limit Maximum nodes to return. If null, all nodes will be returned
915+
* @param self::TRAVERSAL_* $traversalStrategy Strategy to use to traverse tree
916+
*
917+
* @throws InvalidArgumentException if input is invalid
918+
*
919+
* @return QueryBuilder QueryBuilder object
920+
*/
921+
public function getNextNodesQueryBuilder($root, $node = null, int $limit = null, string $traversalStrategy = self::TRAVERSAL_PRE_ORDER)
922+
{
923+
$meta = $this->getClassMetadata();
924+
$config = $this->listener->getConfiguration($this->_em, $meta->getName());
925+
926+
if (self::TRAVERSAL_PRE_ORDER === $traversalStrategy) {
927+
$qb = $this->childrenQueryBuilder($root, false, $config['left'], 'ASC', true);
928+
if (null !== $node) {
929+
$wrapped = new EntityWrapper($node, $this->_em);
930+
$qb
931+
->andWhere($qb->expr()->gt('node.'.$config['left'], ':lft'))
932+
->setParameter('lft', $wrapped->getPropertyValue($config['left']))
933+
;
934+
}
935+
} elseif (self::TRAVERSAL_LEVEL_ORDER === $traversalStrategy) {
936+
if (!isset($config['level'])) {
937+
throw new \InvalidArgumentException('TreeLevel must be set to use level order traversal.');
938+
}
939+
$qb = $this->childrenQueryBuilder($root, false, [$config['level'], $config['left']], ['DESC', 'ASC'], true);
940+
if (null !== $node) {
941+
$wrapped = new EntityWrapper($node, $this->_em);
942+
$qb
943+
->andWhere(
944+
$qb->expr()->orX(
945+
$qb->expr()->andX(
946+
$qb->expr()->gt('node.'.$config['left'], ':lft'),
947+
$qb->expr()->eq('node.'.$config['level'], ':lvl')
948+
),
949+
$qb->expr()->lt('node.'.$config['level'], ':lvl')
950+
)
951+
)
952+
->setParameter('lvl', $wrapped->getPropertyValue($config['level']))
953+
->setParameter('lft', $wrapped->getPropertyValue($config['left']))
954+
;
955+
}
956+
} else {
957+
throw new InvalidArgumentException('Invalid traversal strategy.');
958+
}
959+
960+
if (null !== $limit) {
961+
$qb->setMaxResults($limit);
962+
}
963+
964+
return $qb;
965+
}
966+
967+
/**
968+
* @param object $root Root node of the parsed tree
969+
* @param object|null $node Current node. If null, first node will be returned
970+
* @param int|null $limit Maximum nodes to return. If null, all nodes will be returned
971+
* @param self::TRAVERSAL_* $traversalStrategy Strategy to use to traverse tree
972+
*
973+
* @return Query
974+
*/
975+
public function getNextNodesQuery($root, $node = null, int $limit = null, string $traversalStrategy = self::TRAVERSAL_PRE_ORDER)
976+
{
977+
return $this->getNextNodesQueryBuilder($root, $node, $limit, $traversalStrategy)->getQuery();
978+
}
979+
980+
/**
981+
* @param object $root Root node of the parsed tree
982+
* @param object|null $node Current node. If null, first node will be returned
983+
* @param self::TRAVERSAL_* $traversalStrategy Strategy to use to traverse tree
984+
*
985+
* @return object|null
986+
*/
987+
public function getNextNode($root, $node = null, string $traversalStrategy = self::TRAVERSAL_PRE_ORDER)
988+
{
989+
return $this->getNextNodesQuery($root, $node, 1, $traversalStrategy)->getOneOrNullResult();
990+
}
991+
992+
/**
993+
* @param object $root Root node of the parsed tree
994+
* @param object|null $node Current node. If null, first node will be returned
995+
* @param int|null $limit Maximum nodes to return. If null, all nodes will be returned
996+
* @param self::TRAVERSAL_* $traversalStrategy Strategy to use to traverse tree
997+
*
998+
* @return array<object>
999+
*/
1000+
public function getNextNodes($root, $node = null, int $limit = null, string $traversalStrategy = self::TRAVERSAL_PRE_ORDER)
1001+
{
1002+
return $this->getNextNodesQuery($root, $node, $limit, $traversalStrategy)->getArrayResult();
1003+
}
1004+
9081005
protected function validate()
9091006
{
9101007
return Strategy::NESTED === $this->listener->getStrategy($this->_em, $this->getClassMetadata()->name)->getName();
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the Doctrine Behavioral Extensions package.
7+
* (c) Gediminas Morkevicius <[email protected]> http://www.gediminasm.org
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Gedmo\Tests\Tree;
13+
14+
use Doctrine\Common\EventManager;
15+
use Gedmo\Tests\Tool\BaseTestCaseORM;
16+
use Gedmo\Tests\Tree\Fixture\RootCategory;
17+
use Gedmo\Tree\TreeListener;
18+
19+
/**
20+
* These are tests for Tree traversal
21+
*/
22+
final class NestedTreeTraversalTest extends BaseTestCaseORM
23+
{
24+
public const CATEGORY = RootCategory::class;
25+
26+
protected function setUp(): void
27+
{
28+
parent::setUp();
29+
30+
$evm = new EventManager();
31+
$evm->addEventSubscriber(new TreeListener());
32+
33+
$this->getDefaultMockSqliteEntityManager($evm);
34+
$this->populate();
35+
}
36+
37+
/**
38+
* @dataProvider provideNextNodes
39+
*/
40+
public function testNextNode(array $expected, string $strategy): void
41+
{
42+
$repo = $this->em->getRepository(self::CATEGORY);
43+
$lvl1 = $repo->findOneBy(['title' => 'Part. 1']);
44+
45+
$result = [];
46+
47+
$current = null;
48+
while ($current = $repo->getNextNode($lvl1, $current, $strategy)) {
49+
$result[] = $current->getTitle();
50+
}
51+
static::assertSame($expected, $result);
52+
}
53+
54+
/**
55+
* @dataProvider provideNextNodes
56+
*/
57+
public function testNextNodes(array $expected, string $strategy): void
58+
{
59+
$repo = $this->em->getRepository(self::CATEGORY);
60+
$lvl1 = $repo->findOneBy(['title' => 'Part. 1']);
61+
62+
$nextNodes = $repo->getNextNodes($lvl1, null, 10, $strategy);
63+
64+
static::assertSame($expected, array_column($nextNodes, 'title'));
65+
}
66+
67+
public function provideNextNodes(): iterable
68+
{
69+
yield 'Pre-order traversal' => [
70+
['Part. 1', 'Part. 1.1', 'Part. 1.2', 'Part. 1.2.1', 'Part. 1.2.2', 'Part. 1.3'],
71+
'pre_order',
72+
];
73+
yield 'Level-order traversal' => [
74+
['Part. 1.2.1', 'Part. 1.2.2', 'Part. 1.1', 'Part. 1.2', 'Part. 1.3', 'Part. 1'],
75+
'level_order',
76+
];
77+
}
78+
79+
protected function getUsedEntityFixtures(): array
80+
{
81+
return [self::CATEGORY];
82+
}
83+
84+
/**
85+
* @throws \Doctrine\ORM\OptimisticLockException
86+
*/
87+
private function populate(): void
88+
{
89+
$lvl1 = new RootCategory();
90+
$lvl1->setTitle('Part. 1');
91+
92+
$lvl2 = new RootCategory();
93+
$lvl2->setTitle('Part. 2');
94+
95+
$lvl11 = new RootCategory();
96+
$lvl11->setTitle('Part. 1.1');
97+
$lvl11->setParent($lvl1);
98+
99+
$lvl12 = new RootCategory();
100+
$lvl12->setTitle('Part. 1.2');
101+
$lvl12->setParent($lvl1);
102+
103+
$lvl121 = new RootCategory();
104+
$lvl121->setTitle('Part. 1.2.1');
105+
$lvl121->setParent($lvl12);
106+
107+
$lvl122 = new RootCategory();
108+
$lvl122->setTitle('Part. 1.2.2');
109+
$lvl122->setParent($lvl12);
110+
111+
$lvl13 = new RootCategory();
112+
$lvl13->setTitle('Part. 1.3');
113+
$lvl13->setParent($lvl1);
114+
115+
$lvl21 = new RootCategory();
116+
$lvl21->setTitle('Part. 2.1');
117+
$lvl21->setParent($lvl2);
118+
119+
$lvl22 = new RootCategory();
120+
$lvl22->setTitle('Part. 2.2');
121+
$lvl22->setParent($lvl2);
122+
123+
$this->em->persist($lvl1);
124+
$this->em->persist($lvl2);
125+
$this->em->persist($lvl11);
126+
$this->em->persist($lvl12);
127+
$this->em->persist($lvl121);
128+
$this->em->persist($lvl122);
129+
$this->em->persist($lvl13);
130+
$this->em->persist($lvl21);
131+
$this->em->persist($lvl22);
132+
$this->em->flush();
133+
$this->em->clear();
134+
}
135+
}

0 commit comments

Comments
 (0)