Browse Source

Merge remote-tracking branch 'bmichalski/bmichalski/sortable-search'

Jordi Boggiano 9 years ago
parent
commit
dd37895482

+ 2 - 0
doc/schema.xml

@@ -237,6 +237,8 @@
    <field name="tags" type="text_general_rev" indexed="true" stored="true" multiValued="true"/>
    <field name="type" type="text_general_rev" indexed="true" stored="true"/>
    <field name="trendiness" type="float" indexed="true" stored="true" />
+   <field name="downloads" type="int" indexed="true" stored="true" />
+   <field name="favers" type="int" indexed="true" stored="true" />
    <field name="repository" type="string" indexed="false" stored="true" />
    <field name="abandoned" type="int" indexed="false" stored="true" />
    <field name="replacementPackage" type="string" indexed="false" stored="true" />

+ 16 - 5
src/Packagist/WebBundle/Command/IndexPackagesCommand.php

@@ -13,11 +13,13 @@
 namespace Packagist\WebBundle\Command;
 
 use Packagist\WebBundle\Entity\Package;
-
+use Packagist\WebBundle\Model\DownloadManager;
+use Packagist\WebBundle\Model\FavoriteManager;
+use Solarium_Document_ReadWrite;
 use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand;
+use Symfony\Component\Console\Input\InputArgument;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Input\InputOption;
-use Symfony\Component\Console\Input\InputArgument;
 use Symfony\Component\Console\Output\OutputInterface;
 
 /**
@@ -62,6 +64,8 @@ class IndexPackagesCommand extends ContainerAwareCommand
         $doctrine = $this->getContainer()->get('doctrine');
         $solarium = $this->getContainer()->get('solarium.client');
         $redis = $this->getContainer()->get('snc_redis.default');
+        $downloadManager = $this->getContainer()->get('packagist.download_manager');
+        $favoriteManager = $this->getContainer()->get('packagist.favorite_manager');
 
         $lock = $this->getContainer()->getParameter('kernel.cache_dir').'/composer-indexer.lock';
         $timeout = 600;
@@ -119,7 +123,7 @@ class IndexPackagesCommand extends ContainerAwareCommand
 
                 try {
                     $document = $update->createDocument();
-                    $this->updateDocumentFromPackage($document, $package, $redis);
+                    $this->updateDocumentFromPackage($document, $package, $redis, $downloadManager, $favoriteManager);
                     $update->addDocument($document);
 
                     $package->setIndexedAt(new \DateTime);
@@ -164,13 +168,20 @@ class IndexPackagesCommand extends ContainerAwareCommand
         unlink($lock);
     }
 
-    private function updateDocumentFromPackage(\Solarium_Document_ReadWrite $document, Package $package, $redis)
-    {
+    private function updateDocumentFromPackage(
+        Solarium_Document_ReadWrite $document,
+        Package $package,
+        $redis,
+        DownloadManager $downloadManager,
+        FavoriteManager $favoriteManager
+    ) {
         $document->setField('id', $package->getId());
         $document->setField('name', $package->getName());
         $document->setField('description', $package->getDescription());
         $document->setField('type', $package->getType());
         $document->setField('trendiness', $redis->zscore('downloads:trending', $package->getId()));
+        $document->setField('downloads', $downloadManager->getTotalDownloads($package));
+        $document->setField('favers', $favoriteManager->getFaverCount($package));
         $document->setField('repository', $package->getRepository());
         if ($package->isAbandoned()) {
             $document->setField('abandoned', 1);

+ 149 - 4
src/Packagist/WebBundle/Controller/WebController.php

@@ -247,6 +247,111 @@ class WebController extends Controller
         return $paginator;
     }
 
+    /**
+     * @param array $orderBys
+     *
+     * @return array
+     */
+    protected function getFilteredOrderedBys(array $orderBys)
+    {
+        if ($orderBys) {
+            $allowedSorts = array(
+                'downloads' => 1,
+                'favers' => 1
+            );
+
+            $allowedOrders = array(
+                'asc' => 1,
+                'desc' => 1,
+            );
+
+            $filteredOrderBys = array();
+
+            foreach ($orderBys as $orderBy) {
+                if (isset($orderBy['sort'])
+                    && isset($allowedSorts[$orderBy['sort']])
+                    && isset($orderBy['order'])
+                    && isset($allowedOrders[$orderBy['order']])) {
+                    $filteredOrderBys[] = $orderBy;
+                }
+            }
+        } else {
+            $filteredOrderBys = array();
+        }
+
+        return $filteredOrderBys;
+    }
+
+    /**
+     * @param array $orderBys
+     *
+     * @return array
+     */
+    protected function getNormalizedOrderBys(array $orderBys)
+    {
+        $normalizedOrderBys = array();
+
+        foreach ($orderBys as $sort) {
+            $normalizedOrderBys[$sort['sort']] = $sort['order'];
+        }
+
+        return $normalizedOrderBys;
+    }
+
+    protected function getOrderBysViewModel(Request $req, $normalizedOrderBys)
+    {
+        $makeDefaultArrow = function ($sort) use ($normalizedOrderBys) {
+            if (isset($normalizedOrderBys[$sort])) {
+                if (strtolower($normalizedOrderBys[$sort]) === 'asc') {
+                    $val = 'icon-arrow-up';
+                } else {
+                    $val = 'icon-arrow-down';
+                }
+            } else {
+                $val = '';
+            }
+
+            return $val;
+        };
+
+        $makeDefaultHref = function ($sort) use ($req, $normalizedOrderBys) {
+            if (isset($normalizedOrderBys[$sort])) {
+                if (strtolower($normalizedOrderBys[$sort]) === 'asc') {
+                    $order = 'desc';
+                } else {
+                    $order = 'asc';
+                }
+            } else {
+                $order = 'desc';
+            }
+
+            return '?' . http_build_query(array(
+                'q' => $req->query->get('q') === null ? '' : $req->query->get('q'),
+                'orderBys' => array(
+                    array(
+                        'sort' => $sort,
+                        'order' => $order
+                    )
+                )
+            ));
+        };
+
+        return array(
+            'downloads' => array(
+                'title' => 'Clic to sort by downloads desc',
+                'class' => 'icon-download',
+                'arrowClass' => $makeDefaultArrow('downloads'),
+                'href' => $makeDefaultHref('downloads')
+            ),
+            'favers' => array(
+                'title' => 'Clic to sort by favorites desc',
+                'class' => 'icon-star',
+                'arrowClass' => $makeDefaultArrow('favers'),
+                'href' => $makeDefaultHref('favers')
+            ),
+        );
+    }
+
     /**
      * @Route("/search/", name="search.ajax")
      * @Route("/search.{_format}", requirements={"_format"="(html|json)"}, name="search", defaults={"_format"="html"})
@@ -255,9 +360,33 @@ class WebController extends Controller
     {
         $form = $this->createSearchForm();
 
+        $orderBys = $req->query->get('orderBys', array());
+
+        $filteredOrderBys = $this->getFilteredOrderedBys($orderBys);
+        $normalizedOrderBys = $this->getNormalizedOrderBys($filteredOrderBys);
+
+        if ($req->getRequestFormat() !== 'json' && !$req->isXmlHttpRequest()) {
+            $orderBysViewModel = $this->getOrderBysViewModel($req, $normalizedOrderBys);
+        }
+
         // transform q=search shortcut
-        if ($req->query->has('q')) {
-            $req->query->set('search_query', array('query' => $req->query->get('q')));
+        if ($req->query->has('q') || $req->query->has('orderBys')) {
+            $searchQuery = array();
+
+            $q = $req->query->get('q');
+
+            if ($q !== null) {
+                $searchQuery['query'] = $q;
+            }
+
+            if (!empty($filteredOrderBys)) {
+                $searchQuery['orderBys'] = $filteredOrderBys;
+            }
+
+            $req->query->set(
+                'search_query',
+                $searchQuery
+            );
         }
 
         $typeFilter = $req->query->get('type');
@@ -294,8 +423,13 @@ class WebController extends Controller
                 $select->addFilterQuery($filterQuery);
             }
 
+            if (!empty($filteredOrderBys)) {
+                $select->addSorts($normalizedOrderBys);
+            }
+
             if ($req->query->has('search_query')) {
                 $form->bind($req);
+
                 if ($form->isValid()) {
                     $escapedQuery = $select->getHelper()->escapeTerm($form->getData()->getQuery());
                     $escapedQuery = preg_replace('/(^| )\\\\-(\S)/', '$1-$2', $escapedQuery);
@@ -324,7 +458,14 @@ class WebController extends Controller
 
             $paginator->setCurrentPage($req->query->get('page', 1), false, true);
 
-            $metadata = $this->getPackagesMetadata($paginator);
+            $metadata = array();
+
+            foreach ($paginator as $package) {
+                if (is_numeric($package->id)) {
+                    $metadata['downloads'][$package->id] = $package->downloads;
+                    $metadata['favers'][$package->id] = $package->favers;
+                }
+            }
 
             if ($req->getRequestFormat() === 'json') {
                 try {
@@ -404,6 +545,7 @@ class WebController extends Controller
                 'packages' => $paginator,
                 'meta' => $metadata,
                 'searchForm' => $form->createView(),
+                'orderBys' => $orderBysViewModel
             ));
         } elseif ($req->getRequestFormat() === 'json') {
             return JsonResponse::create(array(
@@ -411,7 +553,10 @@ class WebController extends Controller
             ), 400)->setCallback($req->query->get('callback'));
         }
 
-        return $this->render('PackagistWebBundle:Web:search.html.twig', array('searchForm' => $form->createView()));
+        return $this->render('PackagistWebBundle:Web:search.html.twig', array(
+            'searchForm' => $form->createView(),
+            'orderBys' => $orderBysViewModel
+        ));
     }
 
     /**

+ 49 - 0
src/Packagist/WebBundle/Form/Model/OrderBy.php

@@ -0,0 +1,49 @@
+<?php
+
+/*
+ * This file is part of Packagist.
+ *
+ * (c) Jordi Boggiano <j.boggiano@seld.be>
+ *     Nils Adermann <naderman@naderman.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Packagist\WebBundle\Form\Model;
+
+/**
+ * @author Benjamin Michalski <benjamin.michalski@gmail.com>
+ */
+class OrderBy
+{
+    /**
+     * @var string
+     */
+    protected $sort;
+
+    /**
+     * @var string
+     */
+    protected $order;
+
+    public function setSort($sort)
+    {
+        $this->sort = $sort;
+    }
+
+    public function getSort()
+    {
+        return $this->sort;
+    }
+
+    public function setOrder($order)
+    {
+        $this->order = $order;
+    }
+
+    public function getOrder()
+    {
+        return $this->order;
+    }
+}

+ 15 - 0
src/Packagist/WebBundle/Form/Model/SearchQuery.php

@@ -21,6 +21,11 @@ class SearchQuery
      */
     protected $query;
 
+    /**
+     * @var array
+     */
+    protected $orderBys;
+
     public function setQuery($query)
     {
         $this->query = $query;
@@ -30,4 +35,14 @@ class SearchQuery
     {
         return $this->query;
     }
+
+    public function setOrderBys($orderBys)
+    {
+        $this->orderBys = $orderBys;
+    }
+
+    public function getOrderBys()
+    {
+        return $this->orderBys;
+    }
 }

+ 48 - 0
src/Packagist/WebBundle/Form/Type/OrderByType.php

@@ -0,0 +1,48 @@
+<?php
+
+/*
+ * This file is part of Packagist.
+ *
+ * (c) Jordi Boggiano <j.boggiano@seld.be>
+ *     Nils Adermann <naderman@naderman.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Packagist\WebBundle\Form\Type;
+
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\OptionsResolver\OptionsResolverInterface;
+
+/**
+ * @author Benjamin Michalski <benjamin.michalski@gmail.com>
+ */
+class OrderByType extends AbstractType
+{
+    public function buildForm(FormBuilderInterface $builder, array $options)
+    {
+        $builder->add('sort');
+        $builder->add('order', 'choice', array(
+            'choices' => array(
+                'asc' => 'asc',
+                'desc' => 'desc'
+            )
+        ));
+    }
+
+    public function setDefaultOptions(OptionsResolverInterface $resolver)
+    {
+        $resolver->setDefaults(array(
+            'data_class' => 'Packagist\WebBundle\Form\Model\OrderBy',
+            'csrf_protection' => false,
+        ));
+    }
+
+
+    public function getName()
+    {
+        return 'order_by';
+    }
+}

+ 5 - 0
src/Packagist/WebBundle/Form/Type/SearchQueryType.php

@@ -24,6 +24,11 @@ class SearchQueryType extends AbstractType
     public function buildForm(FormBuilderInterface $builder, array $options)
     {
         $builder->add('query', 'search');
+        $builder->add('orderBys', 'collection', array(
+            'type' => new OrderByType(),
+            'allow_add' => true,
+            'allow_delete' => true
+        ));
     }
 
     public function setDefaultOptions(OptionsResolverInterface $resolver)

+ 34 - 1
src/Packagist/WebBundle/Resources/public/css/main.css

@@ -437,12 +437,45 @@ form ul {
 
 
 /* Search */
+
 #search_query_query {
   width: 890px;
 }
 .no-js #search_query_query {
   width: 780px;
 }
+
+.sortable #search_query_query {
+  width: 737px;
+  display: inline;
+}
+.sortable .no-js #search_query_query {
+  width: 600px;
+}
+
+#search_query_orderBys {
+  display: none;
+}
+
+.sortable #order-bys-wrapper {
+  margin-left: 0.7em;
+  display: inline;
+  font-size: 24px;
+  position: relative;
+  top: 4px;
+}
+
+.sortable #order-bys-wrapper a {
+  width: 46px;
+  margin-right: 7px;
+  display: inline-block;
+}
+
+.sortable #order-bys-wrapper a.clear {
+  width: auto;
+  margin-right: 0px;
+}
+
 #search-form .submit-wrapper {
   width: 100px;
   float: right;
@@ -776,4 +809,4 @@ pre {
   @page { margin: 0.5cm; }
   p, h2, h3 { orphans: 3; widows: 3; }
   h2, h3{ page-break-after: avoid; }
-}
+}

+ 55 - 3
src/Packagist/WebBundle/Resources/public/js/search.js

@@ -28,7 +28,16 @@
     };
 
     doSearch = function () {
-        var currentQuery;
+        var currentQuery,
+            orderBys,
+            orderBysStrParts,
+            joinedOrderBys,
+            joinedOrderBysQryStrPart,
+            q,
+            pathname,
+            urlPrefix,
+            url,
+            title;
 
         if (searching) {
             searchQueued = true;
@@ -49,11 +58,54 @@
         }
 
         if (window.history.pushState) {
+            orderBys = [];
+
+            $('#search_query_orderBys > div').each(function (i, e) {
+                var sort,
+                    order;
+                sort = $(e).find('input').val();
+                order = $(e).find('select').val();
+
+                orderBys.push({
+                    sort: sort,
+                    order: order
+                });
+            });
+
+            orderBysStrParts = [];
+
+            orderBys.forEach(function (e, i) {
+                orderBysStrParts.push('orderBys[' + i + '][sort]=' + e.sort + '&orderBys[' + i + '][order]=' + e.order);
+            });
+
+            joinedOrderBys = orderBysStrParts.join('&');
+
+            q = encodeURIComponent($('input[type="search"]', form).val());
+
+            pathname = window.location.pathname;
+
+            if (pathname.indexOf('/app_dev.php') === 0) {
+                urlPrefix = '/app_dev.php';
+            } else if (pathname.indexOf('/app.php') === 0) {
+                urlPrefix = '/app.php';
+            } else {
+                urlPrefix = '';
+            }
+
+            if (joinedOrderBys === '') {
+                joinedOrderBysQryStrPart = '';
+            } else {
+                joinedOrderBysQryStrPart = '&' + joinedOrderBys;
+            }
+
+            url = urlPrefix + '/search/?q=' + q + joinedOrderBysQryStrPart;
+            title = 'Search';
+
             if (firstQuery) {
-                window.history.pushState(null, "Search", "/search/?q=" + encodeURIComponent($('input[type="search"]', form).val()));
+                window.history.pushState(null, title, url);
                 firstQuery = false;
             } else {
-                window.history.replaceState(null, "Search", "/search/?q=" + encodeURIComponent($('input[type="search"]', form).val()));
+                window.history.replaceState(null, title, url);
             }
         }
 

+ 19 - 2
src/Packagist/WebBundle/Resources/views/Web/searchForm.html.twig

@@ -1,9 +1,26 @@
 <form id="search-form" action="{{ path('search.ajax') }}" method="GET" {{ form_enctype(searchForm) }} autocomplete="off">
     <p class="submit-wrapper"><input class="submit" type="submit" value="Search" /></p>
-    <p>
+    <div class="{% if orderBys is defined %}sortable{% endif %}">
         {{ form_errors(searchForm.query) }}
         {{ form_widget(searchForm.query, {'attr': {'autocomplete': 'off', 'autofocus': 'autofocus', 'placeholder': 'Search packages...', 'tabindex': 1}}) }}
+        {% if orderBys is defined %}
+            <div id="order-bys-wrapper">
+                {% for sort,param in orderBys %}
+                    <a title="{{ param.title }}" href="{{ param.href }}">
+                        <i class="icon {{ param.class }}"></i>
+                        <i class="icon {{ param.arrowClass }}"></i>
+                    </a>
+                {% endfor %}
+                <a title="Clear order bys" href="?q={{ searchForm.vars.value.query }}" class="clear">
+                    <i class="icon icon-remove-circle"></i>
+                </a>
+            </div>
+        {% endif  %}
+        <div
+            class="hidden">
+            {{ form_widget(searchForm.orderBys) }}
+        </div>
         {{ form_rest(searchForm) }}
-    </p>
+    </div>
 </form>
 

+ 415 - 3
src/Packagist/WebBundle/Tests/Controller/WebControllerTest.php

@@ -2,7 +2,12 @@
 
 namespace Packagist\WebBundle\Tests\Controller;
 
+use Exception;
+use Packagist\WebBundle\Entity\Package;
+use Packagist\WebBundle\Model\DownloadManager;
+use Packagist\WebBundle\Model\FavoriteManager;
 use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
+use Symfony\Component\DependencyInjection\ContainerInterface;
 
 class WebControllerTest extends WebTestCase
 {
@@ -13,7 +18,7 @@ class WebControllerTest extends WebTestCase
         $crawler = $client->request('GET', '/');
         $this->assertEquals('Getting Started', $crawler->filter('.getting-started h1')->text());
     }
-    
+
     public function testPackages()
     {
         $client = self::createClient();
@@ -21,15 +26,422 @@ class WebControllerTest extends WebTestCase
         $crawler = $client->request('GET', '/packages/');
         $this->assertTrue($crawler->filter('.packages li')->count() > 0);
     }
-    
+
     public function testPackage()
     {
         $client = self::createClient();
         //we expect package to be clickable and showing at least 'package' div
         $crawler = $client->request('GET', '/packages/');
         $link = $crawler->filter('.packages li h1 a')->first()->attr('href');
-        
+
         $crawler = $client->request('GET', $link);
         $this->assertTrue($crawler->filter('.package')->count() > 0);
     }
+
+    /**
+     * @covers ::nothing
+     */
+    public function testSearchNoOrderBysAction()
+    {
+        $json = $this->commonTestSearchActionOrderBysDownloads();
+
+        $this->assertSame(
+            $this->getJsonResults(
+                array(
+                    $this->getJsonResult('twig/twig', 25, 0),
+                    $this->getJsonResult('composer/packagist', 12, 0),
+                    $this->getJsonResult('symfony/symfony', 42, 0),
+                )
+            ),
+            $json
+        );
+    }
+
+    /**
+     * @covers ::nothing
+     */
+    public function testSearchOrderByDownloadsAscAction()
+    {
+        $json = $this->commonTestSearchActionOrderBysDownloads(
+            array(
+                array(
+                    'sort' => 'downloads',
+                    'order' => 'asc',
+                ),
+            )
+        );
+
+        $this->assertSame(
+            $this->getJsonResults(
+                array(
+                    $this->getJsonResult('composer/packagist', 12, 0),
+                    $this->getJsonResult('twig/twig', 25, 0),
+                    $this->getJsonResult('symfony/symfony', 42, 0),
+                )
+            ),
+            $json
+        );
+    }
+
+    /**
+     * @covers ::nothing
+     */
+    public function testSearchOrderByDownloadsDescAction()
+    {
+        $json = $this->commonTestSearchActionOrderBysDownloads(
+            array(
+                array(
+                    'sort' => 'downloads',
+                    'order' => 'desc',
+                ),
+            )
+        );
+
+        $this->assertSame(
+            $this->getJsonResults(
+                array(
+                    $this->getJsonResult('symfony/symfony', 42, 0),
+                    $this->getJsonResult('twig/twig', 25, 0),
+                    $this->getJsonResult('composer/packagist', 12, 0),
+                )
+            ),
+            $json
+        );
+    }
+
+    /**
+     * @covers ::nothing
+     */
+    public function testSearchOrderByFaversAscAction()
+    {
+        $json = $this->commonTestSearchActionOrderBysFavers(
+            array(
+                array(
+                    'sort' => 'favers',
+                    'order' => 'asc',
+                ),
+            )
+        );
+
+        $this->assertSame(
+            $this->getJsonResults(
+                array(
+                    $this->getJsonResult('composer/packagist', 0, 1),
+                    $this->getJsonResult('twig/twig', 0, 2),
+                    $this->getJsonResult('symfony/symfony', 0, 3),
+                )
+            ),
+            $json
+        );
+    }
+
+    /**
+     * @covers ::nothing
+     */
+    public function testSearchOrderByFaversDescAction()
+    {
+        $json = $this->commonTestSearchActionOrderBysFavers(
+            array(
+                array(
+                    'sort' => 'favers',
+                    'order' => 'desc',
+                ),
+            )
+        );
+
+        $this->assertSame(
+            $this->getJsonResults(
+                array(
+                    $this->getJsonResult('symfony/symfony', 0, 3),
+                    $this->getJsonResult('twig/twig', 0, 2),
+                    $this->getJsonResult('composer/packagist', 0, 1),
+                )
+            ),
+            $json
+        );
+    }
+
+    /**
+     * @covers ::nothing
+     */
+    public function testSearchOrderBysCombinationAction()
+    {
+        $userMock = $this->getMock('Packagist\WebBundle\Entity\User');
+        $userMock1 = $this->getMock('Packagist\WebBundle\Entity\User');
+        $userMock2 = $this->getMock('Packagist\WebBundle\Entity\User');
+
+        $userMock->method('getId')->will($this->returnValue(1));
+        $userMock1->method('getId')->will($this->returnValue(2));
+        $userMock2->method('getId')->will($this->returnValue(3));
+
+        $json = $this->commonTestSearchActionOrderBysAction(
+            function (
+                ContainerInterface $container,
+                Package $twigPackage,
+                Package $packagistPackage,
+                Package $symfonyPackage
+            ) use (
+                $userMock,
+                $userMock1,
+                $userMock2
+            ) {
+                $downloadManager = $container->get('packagist.download_manager');
+
+                /* @var $downloadManager DownloadManager */
+
+                for ($i = 0; $i < 25; $i += 1) {
+                    $downloadManager->addDownload($twigPackage->getId(), 25);
+                }
+                for ($i = 0; $i < 12; $i += 1) {
+                    $downloadManager->addDownload($packagistPackage->getId(), 12);
+                }
+                for ($i = 0; $i < 25; $i += 1) {
+                    $downloadManager->addDownload($symfonyPackage->getId(), 42);
+                }
+
+                $favoriteManager = $container->get('packagist.favorite_manager');
+
+                /* @var $favoriteManager FavoriteManager */
+
+                $favoriteManager->markFavorite($userMock, $packagistPackage);
+
+                $favoriteManager->markFavorite($userMock, $symfonyPackage);
+                $favoriteManager->markFavorite($userMock1, $symfonyPackage);
+                $favoriteManager->markFavorite($userMock2, $symfonyPackage);
+            },
+            array(
+                array(
+                    'sort' => 'downloads',
+                    'order' => 'desc',
+                ),
+                array(
+                    'sort' => 'favers',
+                    'order' => 'desc',
+                ),
+            )
+        );
+
+        $this->assertSame(
+            $this->getJsonResults(
+                array(
+                    $this->getJsonResult('symfony/symfony', 25, 3),
+                    $this->getJsonResult('twig/twig', 25, 0),
+                    $this->getJsonResult('composer/packagist', 12, 1),
+                )
+            ),
+            $json
+        );
+    }
+
+    /**
+     * @param callable $onBeforeIndex TODO Add typehint when migrating to 5.4+
+     * @param array $orderBys
+     *
+     * @return array
+     */
+    protected function commonTestSearchActionOrderBysAction(
+        $onBeforeIndex,
+        array $orderBys = array()
+    ) {
+        $client = self::createClient();
+
+        $container = $client->getContainer();
+
+        $kernelRootDir = $container->getParameter('kernel.root_dir');
+
+        $this->executeCommand($kernelRootDir . '/console doctrine:database:drop --env=test --force', false);
+        $this->executeCommand($kernelRootDir . '/console doctrine:database:create --env=test');
+        $this->executeCommand($kernelRootDir . '/console doctrine:schema:create --env=test');
+        $this->executeCommand($kernelRootDir . '/console redis:flushall --env=test -n');
+
+        $lock = $container->getParameter('kernel.cache_dir').'/composer-indexer.lock';
+
+        $this->executeCommand('rm -f ' . $lock);
+
+        $em = $container->get('doctrine')->getManager();
+
+        if (!empty($orderBys)) {
+            $orderBysQryStrPart = '&' . http_build_query(
+                array(
+                    'orderBys' => $orderBys
+                )
+            );
+        } else {
+            $orderBysQryStrPart = '';
+        }
+
+        $twigPackage = new Package();
+
+        $twigPackage->setName('twig/twig');
+        $twigPackage->setRepository('https://github.com/twig/twig');
+
+        $packagistPackage = new Package();
+
+        $packagistPackage->setName('composer/packagist');
+        $packagistPackage->setRepository('https://github.com/composer/packagist');
+
+        $symfonyPackage = new Package();
+
+        $symfonyPackage->setName('symfony/symfony');
+        $symfonyPackage->setRepository('https://github.com/symfony/symfony');
+
+        $em->persist($twigPackage);
+        $em->persist($packagistPackage);
+        $em->persist($symfonyPackage);
+
+        $em->flush();
+
+        $onBeforeIndex($container, $twigPackage, $packagistPackage, $symfonyPackage);
+
+        $this->executeCommand($kernelRootDir . '/console packagist:index --env=test --force');
+
+        $client->request('GET', '/search.json?q=' . $orderBysQryStrPart);
+
+        $response = $client->getResponse();
+
+        $content = $client->getResponse()->getContent();
+
+        $this->assertSame(200, $response->getStatusCode(), $content);
+
+        return json_decode($content, true);
+    }
+
+    /**
+     * @param array $orderBys
+     *
+     * @return array
+     */
+    protected function commonTestSearchActionOrderBysDownloads(
+        array $orderBys = array()
+    ) {
+        return $this->commonTestSearchActionOrderBysAction(
+            function (
+                ContainerInterface $container,
+                Package $twigPackage,
+                Package $packagistPackage,
+                Package $symfonyPackage
+            ) {
+                $downloadManager = $container->get('packagist.download_manager');
+
+                /* @var $downloadManager DownloadManager */
+
+                for ($i = 0; $i < 25; $i += 1) {
+                    $downloadManager->addDownload($twigPackage->getId(), 25);
+                }
+                for ($i = 0; $i < 12; $i += 1) {
+                    $downloadManager->addDownload($packagistPackage->getId(), 12);
+                }
+                for ($i = 0; $i < 42; $i += 1) {
+                    $downloadManager->addDownload($symfonyPackage->getId(), 42);
+                }
+            },
+            $orderBys
+        );
+    }
+
+    /**
+     * @param array $orderBys
+     *
+     * @return array
+     */
+    protected function commonTestSearchActionOrderBysFavers(
+        array $orderBys = array()
+    ) {
+        $userMock = $this->getMock('Packagist\WebBundle\Entity\User');
+        $userMock1 = $this->getMock('Packagist\WebBundle\Entity\User');
+        $userMock2 = $this->getMock('Packagist\WebBundle\Entity\User');
+
+        $userMock->method('getId')->will($this->returnValue(1));
+        $userMock1->method('getId')->will($this->returnValue(2));
+        $userMock2->method('getId')->will($this->returnValue(3));
+
+        return $this->commonTestSearchActionOrderBysAction(
+            function (
+                ContainerInterface $container,
+                Package $twigPackage,
+                Package $packagistPackage,
+                Package $symfonyPackage
+            ) use (
+                $userMock,
+                $userMock1,
+                $userMock2
+            ) {
+                $favoriteManager = $container->get('packagist.favorite_manager');
+
+                /* @var $favoriteManager FavoriteManager */
+
+                $favoriteManager->markFavorite($userMock, $twigPackage);
+                $favoriteManager->markFavorite($userMock1, $twigPackage);
+
+                $favoriteManager->markFavorite($userMock, $packagistPackage);
+
+                $favoriteManager->markFavorite($userMock, $symfonyPackage);
+                $favoriteManager->markFavorite($userMock1, $symfonyPackage);
+                $favoriteManager->markFavorite($userMock2, $symfonyPackage);
+            },
+            $orderBys
+        );
+    }
+
+    /**
+     * Executes a given command.
+     *
+     * @param string $command a command to execute
+     * @param bool $errorHandling
+     *
+     * @throws Exception when the return code is not 0.
+     */
+    protected function executeCommand(
+        $command,
+        $errorHandling = true
+    ) {
+        $output = array();
+
+        $returnCode = null;;
+
+        exec($command, $output, $returnCode);
+
+        if ($errorHandling && $returnCode !== 0) {
+            throw new Exception(
+                sprintf(
+                    'Error executing command "%s", return code was "%s".',
+                    $command,
+                    $returnCode
+                )
+            );
+        }
+    }
+
+    /**
+     * @param string $package
+     * @param int $downloads
+     * @param int $favers
+     *
+     * @return array
+     */
+    protected function getJsonResult($package, $downloads, $favers)
+    {
+        return array(
+            'name' => $package,
+            'description' => '',
+            'url' => 'http://localhost/packages/' . $package,
+            'repository' => 'https://github.com/' . $package,
+            'downloads' => $downloads,
+            'favers' => $favers,
+        );
+    }
+
+    /**
+     * @param array $results
+     *
+     * @return array
+     */
+    protected function getJsonResults(
+        array $results
+    ) {
+        return array(
+            'results' => $results,
+            'total' => count($results)
+        );
+    }
 }