<NME> CommentVoteAdmin.php
<BEF> ADDFILE
<MSG> Add support for unlimited nested child admins. (#4542)


<DFF> @@ -0,0 +1,13 @@
+<?php
+
+namespace Sonata\AdminBundle\Tests\Fixtures\Admin;
+
+use Sonata\AdminBundle\Admin\AbstractAdmin;
+
+/**
+ * This class is intended to be used when testing with 3-level admin nesting.
+ * PostAdmin -> CommentAdmin -> CommentVoteAdmin
+ */
+class CommentVoteAdmin extends AbstractAdmin
+{
+}

{'commit': 'diff --git a/Admin/AbstractAdmin.php b/Admin/AbstractAdmin.php\nindex 3c11d11b8..4cf8a9709 100644\n--- a/Admin/AbstractAdmin.php\n+++ b/Admin/AbstractAdmin.php\n@@ -50,7 +50,7 @@ use Symfony\\Component\\Validator\\ValidatorInterface as LegacyValidatorInterface;\n /**\n  * @author Thomas Rabaix <thomas.rabaix@sonata-project.org>\n  */\n-abstract class AbstractAdmin implements AdminInterface, DomainObjectInterface\n+abstract class AbstractAdmin implements AdminInterface, DomainObjectInterface, AdminTreeInterface\n {\n     const CONTEXT_MENU = \'menu\';\n     const CONTEXT_DASHBOARD = \'dashboard\';\n@@ -227,6 +227,10 @@ abstract class AbstractAdmin implements AdminInterface, DomainObjectInterface\n     /**\n      * The base code route refer to the prefix used to generate the route name.\n      *\n+     * NEXT_MAJOR: remove this attribute.\n+     *\n+     * @deprecated This attribute is deprecated since 3.x and will be removed in 4.0\n+     *\n      * @var string\n      */\n     protected $baseCodeRoute = \'\';\n@@ -623,6 +627,7 @@ abstract class AbstractAdmin implements AdminInterface, DomainObjectInterface\n             $this->classnameLabel = substr($this->getClass(), strrpos($this->getClass(), \'\\\\\') + 1);\n         }\n \n+        // NEXT_MAJOR: Remove this line.\n         $this->baseCodeRoute = $this->getCode();\n \n         $this->configure();\n@@ -900,8 +905,9 @@ abstract class AbstractAdmin implements AdminInterface, DomainObjectInterface\n                 }\n             }\n \n-            $this->cachedBaseRoutePattern = sprintf(\'%s/{id}/%s\',\n+            $this->cachedBaseRoutePattern = sprintf(\'%s/%s/%s\',\n                 $this->getParent()->getBaseRoutePattern(),\n+                $this->getParent()->getRouterIdParameter(),\n                 $this->baseRoutePattern ?: $this->urlize($matches[5], \'-\')\n             );\n         } elseif ($this->baseRoutePattern) {\n@@ -1140,7 +1146,7 @@ abstract class AbstractAdmin implements AdminInterface, DomainObjectInterface\n      */\n     public function getRouterIdParameter()\n     {\n-        return $this->isChild() ? \'{childId}\' : \'{id}\';\n+        return \'{\'.$this->getIdParameter().\'}\';\n     }\n \n     /**\n@@ -1148,7 +1154,13 @@ abstract class AbstractAdmin implements AdminInterface, DomainObjectInterface\n      */\n     public function getIdParameter()\n     {\n-        return $this->isChild() ? \'childId\' : \'id\';\n+        $parameter = \'id\';\n+\n+        for ($i = 0; $i < $this->getChildDepth(); ++$i) {\n+            $parameter = \'child\'.ucfirst($parameter);\n+        }\n+\n+        return $parameter;\n     }\n \n     /**\n@@ -1893,9 +1905,19 @@ EOT;\n      */\n     public function addChild(AdminInterface $child)\n     {\n+        for ($parentAdmin = $this; null !== $parentAdmin; $parentAdmin = $parentAdmin->getParent()) {\n+            if ($parentAdmin->getCode() !== $child->getCode()) {\n+                continue;\n+            }\n+\n+            throw new \\RuntimeException(sprintf(\n+                \'Circular reference detected! The child admin `%s` is already in the parent tree of the `%s` admin.\',\n+                $child->getCode(), $this->getCode()\n+            ));\n+        }\n+\n         $this->children[$child->getCode()] = $child;\n \n-        $child->setBaseCodeRoute($this->getCode().\'|\'.$child->getCode());\n         $child->setParent($this);\n     }\n \n@@ -1939,6 +1961,54 @@ EOT;\n         return $this->parent;\n     }\n \n+    /**\n+     * {@inheritdoc}\n+     */\n+    final public function getRootAncestor()\n+    {\n+        $parent = $this;\n+\n+        while ($parent->isChild()) {\n+            $parent = $parent->getParent();\n+        }\n+\n+        return $parent;\n+    }\n+\n+    /**\n+     * {@inheritdoc}\n+     */\n+    final public function getChildDepth()\n+    {\n+        $parent = $this;\n+        $depth = 0;\n+\n+        while ($parent->isChild()) {\n+            $parent = $parent->getParent();\n+            ++$depth;\n+        }\n+\n+        return $depth;\n+    }\n+\n+    /**\n+     * {@inheritdoc}\n+     */\n+    final public function getCurrentLeafChildAdmin()\n+    {\n+        $child = $this->getCurrentChildAdmin();\n+\n+        if (null === $child) {\n+            return;\n+        }\n+\n+        for ($c = $child; null !== $c; $c = $child->getCurrentChildAdmin()) {\n+            $child = $c;\n+        }\n+\n+        return $child;\n+    }\n+\n     /**\n      * {@inheritdoc}\n      */\n@@ -2370,10 +2440,19 @@ EOT;\n     }\n \n     /**\n+     * NEXT_MAJOR: Remove this function.\n+     *\n+     * @deprecated This method is deprecated since 3.x and will be removed in 4.0\n+     *\n      * @param string $baseCodeRoute\n      */\n     public function setBaseCodeRoute($baseCodeRoute)\n     {\n+        @trigger_error(\n+            \'The \'.__METHOD__.\' is deprecated since 3.x and will be removed in 4.0.\',\n+            E_USER_DEPRECATED\n+        );\n+\n         $this->baseCodeRoute = $baseCodeRoute;\n     }\n \n@@ -2382,6 +2461,24 @@ EOT;\n      */\n     public function getBaseCodeRoute()\n     {\n+        // NEXT_MAJOR: Uncomment the following lines.\n+        // if ($this->isChild()) {\n+        //     return $this->getParent()->getBaseCodeRoute().\'|\'.$this->getCode();\n+        // }\n+        //\n+        // return $this->getCode();\n+\n+        // NEXT_MAJOR: Remove all the code below.\n+        if ($this->isChild()) {\n+            $parentCode = $this->getParent()->getCode();\n+\n+            if ($this->getParent()->isChild()) {\n+                $parentCode = $this->getParent()->getBaseCodeRoute();\n+            }\n+\n+            return $parentCode.\'|\'.$this->getCode();\n+        }\n+\n         return $this->baseCodeRoute;\n     }\n \ndiff --git a/Admin/AdminTreeInterface.php b/Admin/AdminTreeInterface.php\nnew file mode 100644\nindex 000000000..e2e083fd9\n--- /dev/null\n+++ b/Admin/AdminTreeInterface.php\n@@ -0,0 +1,41 @@\n+<?php\n+\n+/*\n+ * This file is part of the Sonata Project package.\n+ *\n+ * (c) Thomas Rabaix <thomas.rabaix@sonata-project.org>\n+ *\n+ * For the full copyright and license information, please view the LICENSE\n+ * file that was distributed with this source code.\n+ */\n+\n+namespace Sonata\\AdminBundle\\Admin;\n+\n+/**\n+ * @author Jules Lamur <contact@juleslamur.fr>\n+ */\n+interface AdminTreeInterface\n+{\n+    /**\n+     * Returns the root ancestor or itself if not a child.\n+     *\n+     * @return AdminInterface\n+     */\n+    public function getRootAncestor();\n+\n+    /**\n+     * Returns the depth of the admin.\n+     * e.g. 0 if not a child; 2 if child of a child; etc...\n+     *\n+     * @return int\n+     */\n+    public function getChildDepth();\n+\n+    /**\n+     * Returns the current leaf child admin instance,\n+     * or null if there\'s no current child.\n+     *\n+     * @return AdminInterface|null\n+     */\n+    public function getCurrentLeafChildAdmin();\n+}\ndiff --git a/Admin/Pool.php b/Admin/Pool.php\nindex 614a44942..05e4af489 100644\n--- a/Admin/Pool.php\n+++ b/Admin/Pool.php\n@@ -223,13 +223,24 @@ class Pool\n     public function getAdminByAdminCode($adminCode)\n     {\n         $codes = explode(\'|\', $adminCode);\n-        $admin = false;\n+\n+        if (false === $codes) {\n+            return false;\n+        }\n+\n+        $admin = $this->getInstance($codes[0]);\n+        array_shift($codes);\n+\n+        if (empty($codes)) {\n+            return $admin;\n+        }\n+\n         foreach ($codes as $code) {\n-            if ($admin == false) {\n-                $admin = $this->getInstance($code);\n-            } elseif ($admin->hasChild($code)) {\n-                $admin = $admin->getChild($code);\n+            if (!$admin->hasChild($code)) {\n+                return false;\n             }\n+\n+            $admin = $admin->getChild($code);\n         }\n \n         return $admin;\ndiff --git a/Controller/CRUDController.php b/Controller/CRUDController.php\nindex 8e203fef7..c12d0d5c8 100644\n--- a/Controller/CRUDController.php\n+++ b/Controller/CRUDController.php\n@@ -1027,8 +1027,8 @@ class CRUDController extends Controller\n \n         $rootAdmin = $this->admin;\n \n-        if ($this->admin->isChild()) {\n-            $this->admin->setCurrentChild(true);\n+        while ($rootAdmin->isChild()) {\n+            $rootAdmin->setCurrentChild(true);\n             $rootAdmin = $rootAdmin->getParent();\n         }\n \ndiff --git a/Resources/doc/images/child_admin.png b/Resources/doc/images/child_admin.png\nnew file mode 100644\nindex 000000000..3eb7b7554\nBinary files /dev/null and b/Resources/doc/images/child_admin.png differ\ndiff --git a/Resources/doc/index.rst b/Resources/doc/index.rst\nindex b28e1f53f..215ca406e 100644\n--- a/Resources/doc/index.rst\n+++ b/Resources/doc/index.rst\n@@ -39,6 +39,7 @@ The demo website can be found at http://demo.sonata-project.org.\n    reference/getting_started\n    reference/configuration\n    reference/architecture\n+   reference/child_admin\n    reference/dashboard\n    reference/search\n    reference/action_list\ndiff --git a/Resources/doc/reference/architecture.rst b/Resources/doc/reference/architecture.rst\nindex 3e9ceeaa5..3e0c9ef19 100644\n--- a/Resources/doc/reference/architecture.rst\n+++ b/Resources/doc/reference/architecture.rst\n@@ -313,121 +313,5 @@ DIC, handles the ``Admin`` classes, lazy-loading them on demand (to reduce overh\n and matching each of them to a group. It is also responsible for handling the top level\n template files, administration panel title and logo.\n \n-Create child admins\n--------------------\n-\n-Let us say you have a ``PlaylistAdmin`` and a ``VideoAdmin``. You can optionally declare the ``VideoAdmin``\n-to be a child of the ``PlaylistAdmin``. This will create new routes like, for example, ``/playlist/{id}/video/list``,\n-where the videos will automatically be filtered by post.\n-\n-To do this, you first need to call the ``addChild`` method in your ``PlaylistAdmin`` service configuration:\n-\n-.. configuration-block::\n-\n-    .. code-block:: xml\n-\n-        <!-- app/config/config.xml -->\n-        <service id="sonata.admin.playlist" class="AppBundle\\Admin\\PlaylistAdmin">\n-            <!-- ... -->\n-\n-            <call method="addChild">\n-                <argument type="service" id="sonata.admin.video" />\n-            </call>\n-        </service>\n-\n-Then, you have to set the VideoAdmin ``parentAssociationMapping`` attribute to ``playlist`` :\n-\n-.. code-block:: php\n-\n-    <?php\n-\n-    namespace AppBundle\\Admin;\n-\n-    // ...\n-\n-    class VideoAdmin extends AbstractAdmin\n-    {\n-        protected $parentAssociationMapping = \'playlist\';\n-\n-        // OR\n-\n-        public function getParentAssociationMapping()\n-        {\n-            return \'playlist\';\n-        }\n-    }\n-\n-To display the ``VideoAdmin`` extend the menu in your ``PlaylistAdmin`` class:\n-\n-.. code-block:: php\n-\n-    <?php\n-\n-    namespace AppBundle\\Admin;\n-\n-    use Knp\\Menu\\ItemInterface as MenuItemInterface;\n-    use Sonata\\AdminBundle\\Admin\\AbstractAdmin;\n-    use Sonata\\AdminBundle\\Admin\\AdminInterface;\n-\n-    class PlaylistAdmin extends AbstractAdmin\n-    {\n-        // ...\n-\n-        protected function configureSideMenu(MenuItemInterface $menu, $action, AdminInterface $childAdmin = null)\n-        {\n-            if (!$childAdmin && !in_array($action, array(\'edit\', \'show\'))) {\n-                return;\n-            }\n-\n-            $admin = $this->isChild() ? $this->getParent() : $this;\n-            $id = $admin->getRequest()->get(\'id\');\n-\n-            $menu->addChild(\'View Playlist\', array(\'uri\' => $admin->generateUrl(\'show\', array(\'id\' => $id))));\n-\n-            if ($this->isGranted(\'EDIT\')) {\n-                $menu->addChild(\'Edit Playlist\', array(\'uri\' => $admin->generateUrl(\'edit\', array(\'id\' => $id))));\n-            }\n-\n-            if ($this->isGranted(\'LIST\')) {\n-                $menu->addChild(\'Manage Videos\', array(\n-                    \'uri\' => $admin->generateUrl(\'sonata.admin.video.list\', array(\'id\' => $id))\n-                ));\n-            }\n-        }\n-    }\n-\n-It also possible to set a dot-separated value, like ``post.author``, if your parent and child admins are not directly related.\n-\n-Be wary that being a child admin is optional, which means that regular routes\n-will be created regardless of whether you actually need them or not. To get rid\n-of them, you may override the ``configureRoutes`` method::\n-\n-    <?php\n-    \n-    namespace AppBundle\\Admin;\n-\n-    use Sonata\\AdminBundle\\Admin\\AbstractAdmin;\n-    use Sonata\\AdminBundle\\Route\\RouteCollection;\n-\n-    class VideoAdmin extends AbstractAdmin\n-    {\n-        protected $parentAssociationMapping = \'playlist\';\n-\n-        protected function configureRoutes(RouteCollection $collection)\n-        {\n-            if ($this->isChild()) {\n-\n-                // This is the route configuration as a child\n-                $collection->clearExcept([\'show\', \'edit\']);\n-\n-                return;\n-            }\n-\n-            // This is the route configuration as a parent\n-            $collection->clear();\n-\n-        }\n-    }\n-\n .. _`Django Project Website`: http://www.djangoproject.com/\n .. _`CRUD`: http://en.wikipedia.org/wiki/CRUD\ndiff --git a/Resources/doc/reference/child_admin.rst b/Resources/doc/reference/child_admin.rst\nnew file mode 100644\nindex 000000000..56063f86d\n--- /dev/null\n+++ b/Resources/doc/reference/child_admin.rst\n@@ -0,0 +1,129 @@\n+Create child admins\n+-------------------\n+\n+Let us say you have a ``PlaylistAdmin`` and a ``VideoAdmin``. You can optionally declare the ``VideoAdmin``\n+to be a child of the ``PlaylistAdmin``. This will create new routes like, for example, ``/playlist/{id}/video/list``,\n+where the videos will automatically be filtered by post.\n+\n+To do this, you first need to call the ``addChild`` method in your ``PlaylistAdmin`` service configuration:\n+\n+.. configuration-block::\n+\n+    .. code-block:: xml\n+\n+        <!-- app/config/config.xml -->\n+        <service id="sonata.admin.playlist" class="AppBundle\\Admin\\PlaylistAdmin">\n+            <!-- ... -->\n+\n+            <call method="addChild">\n+                <argument type="service" id="sonata.admin.video" />\n+            </call>\n+        </service>\n+\n+Then, you have to set the VideoAdmin ``parentAssociationMapping`` attribute to ``playlist`` :\n+\n+.. code-block:: php\n+\n+    <?php\n+\n+    namespace AppBundle\\Admin;\n+\n+    // ...\n+\n+    class VideoAdmin extends AbstractAdmin\n+    {\n+        protected $parentAssociationMapping = \'playlist\';\n+\n+        // OR\n+\n+        public function getParentAssociationMapping()\n+        {\n+            return \'playlist\';\n+        }\n+    }\n+\n+To display the ``VideoAdmin`` extend the menu in your ``PlaylistAdmin`` class:\n+\n+.. code-block:: php\n+\n+    <?php\n+\n+    namespace AppBundle\\Admin;\n+\n+    use Knp\\Menu\\ItemInterface as MenuItemInterface;\n+    use Sonata\\AdminBundle\\Admin\\AbstractAdmin;\n+    use Sonata\\AdminBundle\\Admin\\AdminInterface;\n+\n+    class PlaylistAdmin extends AbstractAdmin\n+    {\n+        // ...\n+\n+        protected function configureSideMenu(MenuItemInterface $menu, $action, AdminInterface $childAdmin = null)\n+        {\n+            if (!$childAdmin && !in_array($action, array(\'edit\', \'show\'))) {\n+                return;\n+            }\n+\n+            $admin = $this->isChild() ? $this->getParent() : $this;\n+            $id = $admin->getRequest()->get(\'id\');\n+\n+            $menu->addChild(\'View Playlist\', array(\'uri\' => $admin->generateUrl(\'show\', array(\'id\' => $id))));\n+\n+            if ($this->isGranted(\'EDIT\')) {\n+                $menu->addChild(\'Edit Playlist\', array(\'uri\' => $admin->generateUrl(\'edit\', array(\'id\' => $id))));\n+            }\n+\n+            if ($this->isGranted(\'LIST\')) {\n+                $menu->addChild(\'Manage Videos\', array(\n+                    \'uri\' => $admin->generateUrl(\'sonata.admin.video.list\', array(\'id\' => $id))\n+                ));\n+            }\n+        }\n+    }\n+\n+It also possible to set a dot-separated value, like ``post.author``, if your parent and child admins are not directly related.\n+\n+Be wary that being a child admin is optional, which means that regular routes\n+will be created regardless of whether you actually need them or not. To get rid\n+of them, you may override the ``configureRoutes`` method::\n+\n+    <?php\n+\n+    namespace AppBundle\\Admin;\n+\n+    use Sonata\\AdminBundle\\Admin\\AbstractAdmin;\n+    use Sonata\\AdminBundle\\Route\\RouteCollection;\n+\n+    class VideoAdmin extends AbstractAdmin\n+    {\n+        protected $parentAssociationMapping = \'playlist\';\n+\n+        protected function configureRoutes(RouteCollection $collection)\n+        {\n+            if ($this->isChild()) {\n+\n+                // This is the route configuration as a child\n+                $collection->clearExcept([\'show\', \'edit\']);\n+\n+                return;\n+            }\n+\n+            // This is the route configuration as a parent\n+            $collection->clear();\n+\n+        }\n+    }\n+\n+You can nest admins as deep as you wish.\n+\n+Let\'s say you want to add comments to videos.\n+\n+You can then add your ``CommentAdmin`` admin service as a child of\n+the ``VideoAdmin`` admin service.\n+\n+Finally, the admin interface will look like this:\n+\n+.. figure:: ../images/child_admin.png\n+   :align: center\n+   :alt: Child admin interface\n+   :width: 700px\ndiff --git a/Route/DefaultRouteGenerator.php b/Route/DefaultRouteGenerator.php\nindex 5e23b3fb7..84c7365ca 100644\n--- a/Route/DefaultRouteGenerator.php\n+++ b/Route/DefaultRouteGenerator.php\n@@ -74,7 +74,7 @@ class DefaultRouteGenerator implements RouteGeneratorInterface\n     public function generateMenuUrl(AdminInterface $admin, $name, array $parameters = array(), $absolute = UrlGeneratorInterface::ABSOLUTE_PATH)\n     {\n         // if the admin is a child we automatically append the parent\'s id\n-        if ($admin->isChild() && $admin->hasRequest() && $admin->getRequest()->attributes->has($admin->getParent()->getIdParameter())) {\n+        if ($admin->isChild() && $admin->hasRequest()) {\n             // twig template does not accept variable hash key ... so cannot use admin.idparameter ...\n             // switch value\n             if (isset($parameters[\'id\'])) {\n@@ -82,7 +82,9 @@ class DefaultRouteGenerator implements RouteGeneratorInterface\n                 unset($parameters[\'id\']);\n             }\n \n-            $parameters[$admin->getParent()->getIdParameter()] = $admin->getRequest()->attributes->get($admin->getParent()->getIdParameter());\n+            for ($parentAdmin = $admin->getParent(); null !== $parentAdmin; $parentAdmin = $parentAdmin->getParent()) {\n+                $parameters[$parentAdmin->getIdParameter()] = $admin->getRequest()->attributes->get($parentAdmin->getIdParameter());\n+            }\n         }\n \n         // if the admin is linked to a parent FieldDescription (ie, embedded widget)\n@@ -137,21 +139,27 @@ class DefaultRouteGenerator implements RouteGeneratorInterface\n     {\n         $this->loadCache($admin);\n \n-        if ($admin->isChild()) {\n-            return $admin->getBaseCodeRoute().\'.\'.$name;\n-        }\n-\n         // someone provide the fullname\n-        if (array_key_exists($name, $this->caches)) {\n+        if (!$admin->isChild() && array_key_exists($name, $this->caches)) {\n             return $name;\n         }\n \n+        // NEXT_MAJOR: Uncomment the following line.\n+        // $codePrefix = $admin->getBaseCodeRoute();\n+\n+        // NEXT_MAJOR: Remove next 5 lines.\n+        $codePrefix = $admin->getCode();\n+\n+        if ($admin->isChild()) {\n+            $codePrefix = $admin->getBaseCodeRoute();\n+        }\n+\n         // someone provide a code, so it is a child\n         if (strpos($name, \'.\')) {\n-            return $admin->getCode().\'|\'.$name;\n+            return $codePrefix.\'|\'.$name;\n         }\n \n-        return $admin->getCode().\'.\'.$name;\n+        return $codePrefix.\'.\'.$name;\n     }\n \n     /**\ndiff --git a/Route/PathInfoBuilder.php b/Route/PathInfoBuilder.php\nindex 2249401fb..1256c8cb7 100644\n--- a/Route/PathInfoBuilder.php\n+++ b/Route/PathInfoBuilder.php\n@@ -57,11 +57,6 @@ class PathInfoBuilder implements RouteBuilderInterface\n             $collection->add(\'acl\', $admin->getRouterIdParameter().\'/acl\');\n         }\n \n-        // an admin can have only one level of nested child\n-        if ($admin->getParent()) {\n-            return;\n-        }\n-\n         // add children urls\n         foreach ($admin->getChildren() as $children) {\n             $collection->addCollection($children->getRoutes());\ndiff --git a/Tests/Admin/AdminTest.php b/Tests/Admin/AdminTest.php\nindex 5657b9a7b..ec9fbd21a 100644\n--- a/Tests/Admin/AdminTest.php\n+++ b/Tests/Admin/AdminTest.php\n@@ -17,6 +17,7 @@ use Sonata\\AdminBundle\\Admin\\Pool;\n use Sonata\\AdminBundle\\Route\\DefaultRouteGenerator;\n use Sonata\\AdminBundle\\Route\\RoutesCache;\n use Sonata\\AdminBundle\\Tests\\Fixtures\\Admin\\CommentAdmin;\n+use Sonata\\AdminBundle\\Tests\\Fixtures\\Admin\\CommentVoteAdmin;\n use Sonata\\AdminBundle\\Tests\\Fixtures\\Admin\\CommentWithCustomRouteAdmin;\n use Sonata\\AdminBundle\\Tests\\Fixtures\\Admin\\FieldDescription;\n use Sonata\\AdminBundle\\Tests\\Fixtures\\Admin\\FilteredAdmin;\n@@ -370,6 +371,28 @@ class AdminTest extends PHPUnit_Framework_TestCase\n         $this->assertSame($expected.\'/{id}/comment\', $commentAdmin->getBaseRoutePattern());\n     }\n \n+    /**\n+     * @dataProvider provideGetBaseRoutePattern\n+     */\n+    public function testGetBaseRoutePatternWithTwoNestedChildAdmin($objFqn, $expected)\n+    {\n+        $postAdmin = new PostAdmin(\'sonata.post.admin.post\', $objFqn, \'SonataNewsBundle:PostAdmin\');\n+        $commentAdmin = new CommentAdmin(\n+            \'sonata.post.admin.comment\',\n+            \'Application\\Sonata\\NewsBundle\\Entity\\Comment\',\n+            \'SonataNewsBundle:CommentAdmin\'\n+        );\n+        $commentVoteAdmin = new CommentVoteAdmin(\n+            \'sonata.post.admin.comment_vote\',\n+            \'Application\\Sonata\\NewsBundle\\Entity\\CommentVote\',\n+            \'SonataNewsBundle:CommentVoteAdmin\'\n+        );\n+        $commentAdmin->setParent($postAdmin);\n+        $commentVoteAdmin->setParent($commentAdmin);\n+\n+        $this->assertSame($expected.\'/{id}/comment/{childId}/commentvote\', $commentVoteAdmin->getBaseRoutePattern());\n+    }\n+\n     public function testGetBaseRoutePatternWithSpecifedPattern()\n     {\n         $postAdmin = new PostWithCustomRouteAdmin(\'sonata.post.admin.post_with_custom_route\', \'Application\\Sonata\\NewsBundle\\Entity\\Post\', \'SonataNewsBundle:PostWithCustomRouteAdmin\');\n@@ -492,7 +515,11 @@ class AdminTest extends PHPUnit_Framework_TestCase\n         $postAdmin->setRouteGenerator($routeGenerator);\n         $postAdmin->initialize();\n \n-        $commentAdmin = new CommentAdmin(\'sonata.post.admin.comment\', \'Application\\Sonata\\NewsBundle\\Entity\\Comment\', \'SonataNewsBundle:CommentAdmin\');\n+        $commentAdmin = new CommentAdmin(\n+            \'sonata.post.admin.comment\',\n+            \'Application\\Sonata\\NewsBundle\\Entity\\Comment\',\n+            \'SonataNewsBundle:CommentAdmin\'\n+        );\n         $container->set(\'sonata.post.admin.comment\', $commentAdmin);\n         $commentAdmin->setConfigurationPool($pool);\n         $commentAdmin->setRouteBuilder($pathInfo);\n@@ -500,14 +527,33 @@ class AdminTest extends PHPUnit_Framework_TestCase\n         $commentAdmin->initialize();\n \n         $postAdmin->addChild($commentAdmin);\n-        $pool->setAdminServiceIds(array(\'sonata.post.admin.post\', \'sonata.post.admin.comment\'));\n+\n+        $commentVoteAdmin = new CommentVoteAdmin(\n+            \'sonata.post.admin.comment_vote\',\n+            \'Application\\Sonata\\NewsBundle\\Entity\\CommentVote\',\n+            \'SonataNewsBundle:CommentVoteAdmin\'\n+        );\n+        $container->set(\'sonata.post.admin.comment_vote\', $commentVoteAdmin);\n+        $commentVoteAdmin->setConfigurationPool($pool);\n+        $commentVoteAdmin->setRouteBuilder($pathInfo);\n+        $commentVoteAdmin->setRouteGenerator($routeGenerator);\n+        $commentVoteAdmin->initialize();\n+\n+        $commentAdmin->addChild($commentVoteAdmin);\n+        $pool->setAdminServiceIds(array(\n+            \'sonata.post.admin.post\',\n+            \'sonata.post.admin.comment\',\n+            \'sonata.post.admin.comment_vote\',\n+        ));\n \n         $this->assertSame($expected.\'_comment\', $commentAdmin->getBaseRouteName());\n \n         $this->assertTrue($postAdmin->hasRoute(\'show\'));\n         $this->assertTrue($postAdmin->hasRoute(\'sonata.post.admin.post.show\'));\n         $this->assertTrue($postAdmin->hasRoute(\'sonata.post.admin.post|sonata.post.admin.comment.show\'));\n+        $this->assertTrue($postAdmin->hasRoute(\'sonata.post.admin.post|sonata.post.admin.comment|sonata.post.admin.comment_vote.show\'));\n         $this->assertTrue($postAdmin->hasRoute(\'sonata.post.admin.comment.list\'));\n+        $this->assertTrue($postAdmin->hasRoute(\'sonata.post.admin.comment|sonata.post.admin.comment_vote.list\'));\n         $this->assertFalse($postAdmin->hasRoute(\'sonata.post.admin.post|sonata.post.admin.comment.edit\'));\n         $this->assertFalse($commentAdmin->hasRoute(\'edit\'));\n \n@@ -528,8 +574,11 @@ class AdminTest extends PHPUnit_Framework_TestCase\n         $this->assertTrue($postAdmin->isCurrentRoute(\'list\'));\n         $this->assertFalse($postAdmin->isCurrentRoute(\'create\'));\n         $this->assertFalse($commentAdmin->isCurrentRoute(\'list\'));\n+        $this->assertFalse($commentVoteAdmin->isCurrentRoute(\'list\'));\n         $this->assertTrue($commentAdmin->isCurrentRoute(\'list\', \'sonata.post.admin.post\'));\n         $this->assertFalse($commentAdmin->isCurrentRoute(\'edit\', \'sonata.post.admin.post\'));\n+        $this->assertTrue($commentVoteAdmin->isCurrentRoute(\'list\', \'sonata.post.admin.post\'));\n+        $this->assertFalse($commentVoteAdmin->isCurrentRoute(\'edit\', \'sonata.post.admin.post\'));\n     }\n \n     /**\n@@ -557,6 +606,29 @@ class AdminTest extends PHPUnit_Framework_TestCase\n         $this->assertSame(\'admin_sonata_news_post_comment_custom\', $commentAdmin->getBaseRouteName());\n     }\n \n+    public function testGetBaseRouteNameWithTwoNestedChildAdminAndWithSpecifiedName()\n+    {\n+        $postAdmin = new PostAdmin(\n+            \'sonata.post.admin.post\',\n+            \'Application\\Sonata\\NewsBundle\\Entity\\Post\',\n+            \'SonataNewsBundle:PostAdmin\'\n+        );\n+        $commentAdmin = new CommentWithCustomRouteAdmin(\n+            \'sonata.post.admin.comment_with_custom_route\',\n+            \'Application\\Sonata\\NewsBundle\\Entity\\Comment\',\n+            \'SonataNewsBundle:CommentWithCustomRouteAdmin\'\n+        );\n+        $commentVoteAdmin = new CommentVoteAdmin(\n+            \'sonata.post.admin.comment_vote\',\n+            \'Application\\Sonata\\NewsBundle\\Entity\\CommentVote\',\n+            \'SonataNewsBundle:CommentVoteAdmin\'\n+        );\n+        $commentAdmin->setParent($postAdmin);\n+        $commentVoteAdmin->setParent($commentAdmin);\n+\n+        $this->assertSame(\'admin_sonata_news_post_comment_custom_commentvote\', $commentVoteAdmin->getBaseRouteName());\n+    }\n+\n     /**\n      * @covers \\Sonata\\AdminBundle\\Admin\\AbstractAdmin::setUniqid\n      * @covers \\Sonata\\AdminBundle\\Admin\\AbstractAdmin::getUniqid\n@@ -837,6 +909,11 @@ class AdminTest extends PHPUnit_Framework_TestCase\n         $this->assertSame($modelManager, $admin->getModelManager());\n     }\n \n+    /**\n+     * NEXT_MAJOR: remove this method.\n+     *\n+     * @group legacy\n+     */\n     public function testGetBaseCodeRoute()\n     {\n         $admin = new PostAdmin(\'sonata.post.admin.post\', \'NewsBundle\\Entity\\Post\', \'SonataNewsBundle:PostAdmin\');\n@@ -847,6 +924,30 @@ class AdminTest extends PHPUnit_Framework_TestCase\n         $this->assertSame(\'foo\', $admin->getBaseCodeRoute());\n     }\n \n+    // NEXT_MAJOR: uncomment this method.\n+    // public function testGetBaseCodeRoute()\n+    // {\n+    //     $postAdmin = new PostAdmin(\n+    //         \'sonata.post.admin.post\',\n+    //         \'NewsBundle\\Entity\\Post\',\n+    //         \'SonataNewsBundle:PostAdmin\'\n+    //     );\n+    //     $commentAdmin = new CommentAdmin(\n+    //         \'sonata.post.admin.comment\',\n+    //         \'Application\\Sonata\\NewsBundle\\Entity\\Comment\',\n+    //         \'SonataNewsBundle:CommentAdmin\'\n+    //     );\n+    //\n+    //     $this->assertSame($postAdmin->getCode(), $postAdmin->getBaseCodeRoute());\n+    //\n+    //     $postAdmin->addChild($commentAdmin);\n+    //\n+    //     $this->assertSame(\n+    //         \'sonata.post.admin.post|sonata.post.admin.comment\',\n+    //         $commentAdmin->getBaseCodeRoute()\n+    //     );\n+    // }\n+\n     public function testGetRouteGenerator()\n     {\n         $admin = new PostAdmin(\'sonata.post.admin.post\', \'NewsBundle\\Entity\\Post\', \'SonataNewsBundle:PostAdmin\');\n@@ -1080,16 +1181,34 @@ class AdminTest extends PHPUnit_Framework_TestCase\n \n     public function testGetIdParameter()\n     {\n-        $admin = new PostAdmin(\'sonata.post.admin.post\', \'NewsBundle\\Entity\\Post\', \'SonataNewsBundle:PostAdmin\');\n+        $postAdmin = new PostAdmin(\n+            \'sonata.post.admin.post\',\n+            \'NewsBundle\\Entity\\Post\',\n+            \'SonataNewsBundle:PostAdmin\'\n+        );\n+\n+        $this->assertSame(\'id\', $postAdmin->getIdParameter());\n+        $this->assertFalse($postAdmin->isChild());\n+\n+        $commentAdmin = new CommentAdmin(\n+            \'sonata.post.admin.comment\',\n+            \'Application\\Sonata\\NewsBundle\\Entity\\Comment\',\n+            \'SonataNewsBundle:CommentAdmin\'\n+        );\n+        $commentAdmin->setParent($postAdmin);\n \n-        $this->assertSame(\'id\', $admin->getIdParameter());\n-        $this->assertFalse($admin->isChild());\n+        $this->assertTrue($commentAdmin->isChild());\n+        $this->assertSame(\'childId\', $commentAdmin->getIdParameter());\n \n-        $parentAdmin = new PostAdmin(\'sonata.post.admin.post_parent\', \'NewsBundle\\Entity\\Post\', \'SonataNewsBundle:PostParentAdmin\');\n-        $admin->setParent($parentAdmin);\n+        $commentVoteAdmin = new CommentVoteAdmin(\n+            \'sonata.post.admin.comment_vote\',\n+            \'Application\\Sonata\\NewsBundle\\Entity\\CommentVote\',\n+            \'SonataNewsBundle:CommentVoteAdmin\'\n+        );\n+        $commentVoteAdmin->setParent($commentAdmin);\n \n-        $this->assertTrue($admin->isChild());\n-        $this->assertSame(\'childId\', $admin->getIdParameter());\n+        $this->assertTrue($commentVoteAdmin->isChild());\n+        $this->assertSame(\'childChildId\', $commentVoteAdmin->getIdParameter());\n     }\n \n     public function testGetExportFormats()\n@@ -2065,6 +2184,177 @@ class AdminTest extends PHPUnit_Framework_TestCase\n         $admin->getDataSourceIterator();\n     }\n \n+    public function testCircularChildAdmin()\n+    {\n+        $this->expectException(\n+            \'RuntimeException\',\n+            \'Circular reference detected! The child admin `sonata.post.admin.post` is already in the parent tree of the `sonata.post.admin.comment` admin.\'\n+        );\n+\n+        $postAdmin = new PostAdmin(\n+            \'sonata.post.admin.post\',\n+            \'Application\\Sonata\\NewsBundle\\Entity\\Post\',\n+            \'SonataNewsBundle:PostAdmin\'\n+        );\n+        $commentAdmin = new CommentAdmin(\n+            \'sonata.post.admin.comment\',\n+            \'Application\\Sonata\\NewsBundle\\Entity\\Comment\',\n+            \'SonataNewsBundle:CommentAdmin\'\n+        );\n+        $postAdmin->addChild($commentAdmin);\n+        $commentAdmin->addChild($postAdmin);\n+    }\n+\n+    public function testCircularChildAdminTripleLevel()\n+    {\n+        $this->expectException(\n+            \'RuntimeException\',\n+            \'Circular reference detected! The child admin `sonata.post.admin.post` is already in the parent tree of the `sonata.post.admin.comment_vote` admin.\'\n+        );\n+\n+        $postAdmin = new PostAdmin(\n+            \'sonata.post.admin.post\',\n+            \'Application\\Sonata\\NewsBundle\\Entity\\Post\',\n+            \'SonataNewsBundle:PostAdmin\'\n+        );\n+        $commentAdmin = new CommentAdmin(\n+            \'sonata.post.admin.comment\',\n+            \'Application\\Sonata\\NewsBundle\\Entity\\Comment\',\n+            \'SonataNewsBundle:CommentAdmin\'\n+        );\n+        $commentVoteAdmin = new CommentVoteAdmin(\n+            \'sonata.post.admin.comment_vote\',\n+            \'Application\\Sonata\\NewsBundle\\Entity\\CommentVote\',\n+            \'SonataNewsBundle:CommentVoteAdmin\'\n+        );\n+        $postAdmin->addChild($commentAdmin);\n+        $commentAdmin->addChild($commentVoteAdmin);\n+        $commentVoteAdmin->addChild($postAdmin);\n+    }\n+\n+    public function testCircularChildAdminWithItself()\n+    {\n+        $this->expectException(\n+            \'RuntimeException\',\n+            \'Circular reference detected! The child admin `sonata.post.admin.post` is already in the parent tree of the `sonata.post.admin.post` admin.\'\n+        );\n+\n+        $postAdmin = new PostAdmin(\n+            \'sonata.post.admin.post\',\n+            \'Application\\Sonata\\NewsBundle\\Entity\\Post\',\n+            \'SonataNewsBundle:PostAdmin\'\n+        );\n+        $postAdmin->addChild($postAdmin);\n+    }\n+\n+    public function testGetRootAncestor()\n+    {\n+        $postAdmin = new PostAdmin(\n+            \'sonata.post.admin.post\',\n+            \'Application\\Sonata\\NewsBundle\\Entity\\Post\',\n+            \'SonataNewsBundle:PostAdmin\'\n+        );\n+        $commentAdmin = new CommentAdmin(\n+            \'sonata.post.admin.comment\',\n+            \'Application\\Sonata\\NewsBundle\\Entity\\Comment\',\n+            \'SonataNewsBundle:CommentAdmin\'\n+        );\n+        $commentVoteAdmin = new CommentVoteAdmin(\n+            \'sonata.post.admin.comment_vote\',\n+            \'Application\\Sonata\\NewsBundle\\Entity\\CommentVote\',\n+            \'SonataNewsBundle:CommentVoteAdmin\'\n+        );\n+\n+        $this->assertSame($postAdmin, $postAdmin->getRootAncestor());\n+        $this->assertSame($commentAdmin, $commentAdmin->getRootAncestor());\n+        $this->assertSame($commentVoteAdmin, $commentVoteAdmin->getRootAncestor());\n+\n+        $postAdmin->addChild($commentAdmin);\n+\n+        $this->assertSame($postAdmin, $postAdmin->getRootAncestor());\n+        $this->assertSame($postAdmin, $commentAdmin->getRootAncestor());\n+        $this->assertSame($commentVoteAdmin, $commentVoteAdmin->getRootAncestor());\n+\n+        $commentAdmin->addChild($commentVoteAdmin);\n+\n+        $this->assertSame($postAdmin, $postAdmin->getRootAncestor());\n+        $this->assertSame($postAdmin, $commentAdmin->getRootAncestor());\n+        $this->assertSame($postAdmin, $commentVoteAdmin->getRootAncestor());\n+    }\n+\n+    public function testGetChildDepth()\n+    {\n+        $postAdmin = new PostAdmin(\n+            \'sonata.post.admin.post\',\n+            \'Application\\Sonata\\NewsBundle\\Entity\\Post\',\n+            \'SonataNewsBundle:PostAdmin\'\n+        );\n+        $commentAdmin = new CommentAdmin(\n+            \'sonata.post.admin.comment\',\n+            \'Application\\Sonata\\NewsBundle\\Entity\\Comment\',\n+            \'SonataNewsBundle:CommentAdmin\'\n+        );\n+        $commentVoteAdmin = new CommentVoteAdmin(\n+            \'sonata.post.admin.comment_vote\',\n+            \'Application\\Sonata\\NewsBundle\\Entity\\CommentVote\',\n+            \'SonataNewsBundle:CommentVoteAdmin\'\n+        );\n+\n+        $this->assertSame(0, $postAdmin->getChildDepth());\n+        $this->assertSame(0, $commentAdmin->getChildDepth());\n+        $this->assertSame(0, $commentVoteAdmin->getChildDepth());\n+\n+        $postAdmin->addChild($commentAdmin);\n+\n+        $this->assertSame(0, $postAdmin->getChildDepth());\n+        $this->assertSame(1, $commentAdmin->getChildDepth());\n+        $this->assertSame(0, $commentVoteAdmin->getChildDepth());\n+\n+        $commentAdmin->addChild($commentVoteAdmin);\n+\n+        $this->assertSame(0, $postAdmin->getChildDepth());\n+        $this->assertSame(1, $commentAdmin->getChildDepth());\n+        $this->assertSame(2, $commentVoteAdmin->getChildDepth());\n+    }\n+\n+    public function testGetCurrentLeafChildAdmin()\n+    {\n+        $postAdmin = new PostAdmin(\n+            \'sonata.post.admin.post\',\n+            \'Application\\Sonata\\NewsBundle\\Entity\\Post\',\n+            \'SonataNewsBundle:PostAdmin\'\n+        );\n+        $commentAdmin = new CommentAdmin(\n+            \'sonata.post.admin.comment\',\n+            \'Application\\Sonata\\NewsBundle\\Entity\\Comment\',\n+            \'SonataNewsBundle:CommentAdmin\'\n+        );\n+        $commentVoteAdmin = new CommentVoteAdmin(\n+            \'sonata.post.admin.comment_vote\',\n+            \'Application\\Sonata\\NewsBundle\\Entity\\CommentVote\',\n+            \'SonataNewsBundle:CommentVoteAdmin\'\n+        );\n+\n+        $postAdmin->addChild($commentAdmin);\n+        $commentAdmin->addChild($commentVoteAdmin);\n+\n+        $this->assertNull($postAdmin->getCurrentLeafChildAdmin());\n+        $this->assertNull($commentAdmin->getCurrentLeafChildAdmin());\n+        $this->assertNull($commentVoteAdmin->getCurrentLeafChildAdmin());\n+\n+        $commentAdmin->setCurrentChild(true);\n+\n+        $this->assertSame($commentAdmin, $postAdmin->getCurrentLeafChildAdmin());\n+        $this->assertNull($commentAdmin->getCurrentLeafChildAdmin());\n+        $this->assertNull($commentVoteAdmin->getCurrentLeafChildAdmin());\n+\n+        $commentVoteAdmin->setCurrentChild(true);\n+\n+        $this->assertSame($commentVoteAdmin, $postAdmin->getCurrentLeafChildAdmin());\n+        $this->assertSame($commentVoteAdmin, $commentAdmin->getCurrentLeafChildAdmin());\n+        $this->assertNull($commentVoteAdmin->getCurrentLeafChildAdmin());\n+    }\n+\n     private function createTagAdmin(Post $post)\n     {\n         $postAdmin = $this->getMockBuilder(\'Sonata\\AdminBundle\\Tests\\Fixtures\\Admin\\PostAdmin\')\ndiff --git a/Tests/Admin/PoolTest.php b/Tests/Admin/PoolTest.php\nindex 1da09017a..2678aecbe 100644\n--- a/Tests/Admin/PoolTest.php\n+++ b/Tests/Admin/PoolTest.php\n@@ -216,6 +216,26 @@ class PoolTest extends PHPUnit_Framework_TestCase\n         $this->assertSame(\'commentAdminClass\', $this->pool->getAdminByAdminCode(\'sonata.news.admin.post|sonata.news.admin.comment\'));\n     }\n \n+    public function testGetAdminByAdminCodeForChildInvalidClass()\n+    {\n+        $adminMock = $this->getMockBuilder(\'Sonata\\AdminBundle\\Admin\\AdminInterface\')\n+            ->disableOriginalConstructor()\n+            ->getMock();\n+        $adminMock->expects($this->any())\n+            ->method(\'hasChild\')\n+            ->will($this->returnValue(false));\n+\n+        $containerMock = $this->createMock(\'Symfony\\Component\\DependencyInjection\\ContainerInterface\');\n+        $containerMock->expects($this->any())\n+            ->method(\'get\')\n+            ->will($this->returnValue($adminMock));\n+\n+        $this->pool = new Pool($containerMock, \'Sonata\', \'/path/to/logo.png\');\n+        $this->pool->setAdminServiceIds(array(\'sonata.news.admin.post\'));\n+\n+        $this->assertSame(false, $this->pool->getAdminByAdminCode(\'sonata.news.admin.post|sonata.news.admin.invalid\'));\n+    }\n+\n     public function testGetAdminClasses()\n     {\n         $this->pool->setAdminClasses(array(\'someclass\' => \'sonata.user.admin.group1\'));\ndiff --git a/Tests/Fixtures/Admin/CommentVoteAdmin.php b/Tests/Fixtures/Admin/CommentVoteAdmin.php\nnew file mode 100644\nindex 000000000..b2468dd45\n--- /dev/null\n+++ b/Tests/Fixtures/Admin/CommentVoteAdmin.php\n@@ -0,0 +1,13 @@\n+<?php\n+\n+namespace Sonata\\AdminBundle\\Tests\\Fixtures\\Admin;\n+\n+use Sonata\\AdminBundle\\Admin\\AbstractAdmin;\n+\n+/**\n+ * This class is intended to be used when testing with 3-level admin nesting.\n+ * PostAdmin -> CommentAdmin -> CommentVoteAdmin\n+ */\n+class CommentVoteAdmin extends AbstractAdmin\n+{\n+}\ndiff --git a/UPGRADE-3.x.md b/UPGRADE-3.x.md\nindex 3c609bf19..d33d4fc4c 100644\n--- a/UPGRADE-3.x.md\n+++ b/UPGRADE-3.x.md\n@@ -1,6 +1,16 @@\n UPGRADE 3.x\n ===========\n \n+## Deprecated AbstractAdmin::setBaseCodeRoute() and AbstractAdmin::baseCodeRoute property\n+\n+The `AbstractAdmin::baseCodeRoute` property is no longer supported.\n+Please use the `AbstractAdmin::getBaseCodeRoute()` method instead.\n+\n+The `AbstractAdmin::setBaseCodeRoute()` method is no longer supported.\n+There is no replacement for this method.\n+You can still use the `AbstractAdmin::setCode()` method to set the code\n+of an admin.\n+\n UPGRADE FROM 3.20 to 3.21\n =========================\n \n', 'commit_subject': 'Add support for unlimited nested child admins. (#4542)', 'repo_name': 'sonata-project/SonataAdminBundle', 'committer': {'name': 'Grégoire Paris', 'email': '97607be36098a11ebbd418e8cb692e80c46220f1@greg0ire.fr', 'time_sec': '1506610124', 'tz_offset': '120', 'date': {'seconds': '1506610124'}}, 'commit_tree': '8b08fb9dcc4c11c784e0300b9b52711afa2fadfc', 'commit_message': 'Add support for unlimited nested child admins. (#4542)\n\n', 'language_name': 'PHP', 'license': 'mit', 'hunks': '@@ -0,0 +1,13 @@\n+<?php\n+\n+namespace Sonata\\AdminBundle\\Tests\\Fixtures\\Admin;\n+\n+use Sonata\\AdminBundle\\Admin\\AbstractAdmin;\n+\n+/**\n+ * This class is intended to be used when testing with 3-level admin nesting.\n+ * PostAdmin -> CommentAdmin -> CommentVoteAdmin\n+ */\n+class CommentVoteAdmin extends AbstractAdmin\n+{\n+}\n', 'addition_count': 13, 'deletion_count': 0, 'src_file': '/dev/null', 'tgt_file': 'b/Tests/Fixtures/Admin/CommentVoteAdmin.php', 'file_extension': '.php', 'before_file': 'ADDFILE', 'message': 'Add support for unlimited nested child admins. (#4542)\n\n', 'author_name': 'Grégoire Paris', 'lang': 'php'}