If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeThe response is returning a paginated list, and it even has extra count
and total
fields. Now we need to add those next
, previous
, first
and last
links. And since the response is entirely created via this PaginatedCollection
class, this is simple: just add a new private $_links = array();
property:
... lines 1 - 4 | |
class PaginatedCollection | |
{ | |
... lines 7 - 12 | |
private $_links = array(); | |
... lines 14 - 25 | |
} |
To actually add links, create a new function called public function addLink()
that has two arguments: the $ref
- that's the name of the link, like first
or last
- and the $url
. Add the link with $this->_links[$ref] = $url;
. Great - now head back to the controller:
... lines 1 - 4 | |
class PaginatedCollection | |
{ | |
... lines 7 - 21 | |
public function addLink($ref, $url) | |
{ | |
$this->_links[$ref] = $url; | |
} | |
} |
Every link will point to the same route, but with a different page
query parameter. The route to this controller doesn't have a name yet, so give it one: api_programmers_collection
. Copy that name and set it to a $route
variable:
... lines 1 - 21 | |
class ProgrammerController extends BaseController | |
{ | |
... lines 24 - 75 | |
/** | |
* @Route("/api/programmers", name="api_programmers_collection") | |
* @Method("GET") | |
*/ | |
public function listAction(Request $request) | |
{ | |
... lines 82 - 98 | |
$route = 'api_programmers_collection'; | |
... lines 100 - 120 | |
} | |
... lines 122 - 220 | |
} |
Next, create $routeParams
: this will hold any wildcards that need to be passed to the route - meaning the curly brace parts in its path. This route doesn't have any, so set leave it empty. We're already setting things up to be reusable for other paginated responses.
Since we need to generate four links, create an anonymous function to help out with this: $createLinkUrl = function()
. Give it one argument $targetPage
. Also, add use
for $route
and $routeParams
so we can access those inside. To generate the URL, use the normal return $this->generateURL()
passing it the $route
and an array_merge()
of any routeParams
with a new page
key:
... lines 1 - 98 | |
$route = 'api_programmers_collection'; | |
$routeParams = array(); | |
$createLinkUrl = function($targetPage) use ($route, $routeParams) { | |
return $this->generateUrl($route, array_merge( | |
$routeParams, | |
array('page' => $targetPage) | |
)); | |
}; | |
... lines 107 - 222 |
Since there's no {page}
routing wildcard, the router will add a ?page=
query parameter to the end, exactly how we want it to.
Sweet! Add the first link with $paginatedCollection->addLink()
. Call this link self
and use $page
to point to the current page. It might seem silly to link to this page, but it's a pretty standard thing to do:
... lines 1 - 107 | |
$paginatedCollection->addLink('self', $createLinkUrl($page)); | |
... lines 109 - 222 |
Copy this line and paste it twice. Name the second link first
instead of self
and point this to page 1. Name the third link last
and have it generate a URL to the last page: $pagerfanta->getNbPages()
:
... lines 1 - 108 | |
$paginatedCollection->addLink('first', $createLinkUrl(1)); | |
$paginatedCollection->addLink('last', $createLinkUrl($pagerfanta->getNbPages())); | |
... lines 111 - 222 |
The last two links are next
and previous
... but wait! We don't always have a next or previous page: these should be conditional. Add: if($pagerfanta->hasNextPage())
, well, then, of course we want to generate a link to $pagerfanta->getNextPage()
that's called next
:
... lines 1 - 110 | |
if ($pagerfanta->hasNextPage()) { | |
$paginatedCollection->addLink('next', $createLinkUrl($pagerfanta->getNextPage())); | |
} | |
... lines 114 - 222 |
Do this same thing for the previous
page. if($pagerfanta->hasPreviousPage())
, then getPreviousPage()
and call that link prev
:
... lines 1 - 113 | |
if ($pagerfanta->hasPreviousPage()) { | |
$paginatedCollection->addLink('prev', $createLinkUrl($pagerfanta->getPreviousPage())); | |
} | |
... lines 117 - 222 |
Phew!
With some luck, the test should pass:
./bin/phpunit -c app --filter testGETProgrammersCollectionPaginated
Rerun it aaaannnddd perfect! This is pretty cool: the tests actually follow those links: walking from page 1 to page 2 to page 3 and asserting things along the way.
The link keys - self
, first
, last
, next
and prev
are actually called link rels
, or relations. They have a very important purpose: to explain the meaning of the link. On the web, the link's text tells us what that link points to. In an API, the "rel" does that job.
In other words, as long as our API client understands first
means the first page of results and next
means the next page of results, you can communicate the significance of what those links are.
And you know what else? I didn't just invent these link rels. They're super-official IANA rels - an organization that tries to standardize some of this stuff. Why is that cool? Because if everyone used these same links for pagination, understanding API's would be easier and more consistent.
We are going to talk about links a lot more in a future episode - including all those buzzwords like hypermedia and HATEOAS. So sit tight.
Hi,
Great tutorial,
But why not using HATEOS bundle to generate links like you do in silex rest tutorial ?
Thanks.
Hey Chuck!
Thank you!
Great question! HATEOS bundle is really cool! But we don't want to show this topic in basics and kept it for the next episode (Symfony REST 5) which was already released a few weeks ago. You can check it http://knpuniversity.com/sc... .
Cheers!
Hi,
thanks for the quick answer.
I just saw that when I take a quick look to the rest of the course.
Any way, great job.
Hi,
When I pass a query (e.g. $em->createQuery
), instead of a query builder to this paginator, I get an error: <strong>"The Paginator does not support Queries which only yield ScalarResults."</strong>.
Is there a paginator I can use with a query that returns scalar results?
Thank you!
Hey Vlad!
Hmm, interesting! I don't have experience doing this, but in theory, I would be surprised if it's not supported. The error specifically is coming from Doctrine (not the paginator library itself), so changing libraries (probably) won't help.
Could you post your query? By looking at the code in Doctrine near where that error is thrown, it looks like it might be complaining that there is no "root alias" in the query, which means it may just be the query itself that's causing the problems. If you haven't tried it yet, try including the primary key (e.g. alias.id) in the results.
Btw - link to the code in Doctrine fwiw - it's complex, but might help :) https://github.com/doctrine...
Cheers!
Hi Ryan,
I just got it to work! Turns out I had to disable output walkers.
I changed the following line in PaginationFactory::createCollection()
new DoctrineORMAdapter($queryBuilder);```
to
new DoctrineORMAdapter($queryBuilder, true, false);`
thus setting the <strong>$useOutputWalkers</strong> parameter to <strong>false</strong>.
My query is a DTO query with the <strong>NEW</strong> operator, with 5 joins.
What are output walkers anyway, and in which cases are they needed?
Thank you for your hints.
Wow, nice work!
So, output walkers are really advanced. An output walker - which is a less common thing to worry about, there are also tree walkers, which modify the AST - is responsible for turning the "DQL" (represented by an abstract syntax tree - AST) into the actual SQL. In essence, this the actual code that turns the AST into the actual query string. Here's the default walker: https://github.com/doctrine...
In the pagination library, they use a sub-class of this walker: https://github.com/doctrine... - which helps to "count" the query result, used for pagination. That's what you turned off. I'm honestly not sure what the result of that is - but if it works, do it :). Pagination is quite "magical" - since Doctrine needs to take your query and dynamically change it so that it can first get a COUNT of those potential results (without actually fetching all of them). The only thing I'd double-check is that the pagination library is not now querying for ALL the rows, just to get a count of them. Double-check that in the profiler.
Cheers!
Hi Ryan,
I was also able to accomplish this by using a custom ORM adapter that implements <strong>AdapterInterface</strong> and the 2 interface methods. Its constructor has two parameters: a query to get the collection of items and a query to get the total number of items.
Here is the code:
use Doctrine\ORM\QueryBuilder;
use Pagerfanta\Adapter\AdapterInterface;
/**
* Class CustomORMAdapter
* @package AppBundle\Pagination
*/
class CustomORMAdapter implements AdapterInterface
{
/**
* @var QueryBuilder
*/
private $queryBuilder;
/**
* @var QueryBuilder
*/
private $countQueryBuilder;
/**
* Custom ORM Adapter constructor.
*
* @param QueryBuilder $queryBuilder Query builder for the query that returns the collection of items
* @param QueryBuilder $countQueryBuilder Query builder for the query that returns total number of items
*/
public function __construct(QueryBuilder $queryBuilder, QueryBuilder $countQueryBuilder)
{
$this->queryBuilder = $queryBuilder;
$this->countQueryBuilder = $countQueryBuilder;
}
/**
* Returns the number of results.
*
* @return integer The number of results.
* @throws \Doctrine\ORM\NonUniqueResultException
* @throws \Doctrine\ORM\NoResultException
*/
public function getNbResults()
{
return $this->countQueryBuilder
->setMaxResults(1)
->getQuery()
->getSingleScalarResult();
}
/**
* Returns an slice of the results.
*
* @param integer $offset The offset.
* @param integer $length The length.
*
* @return array|\Traversable The slice.
*/
public function getSlice($offset, $length)
{
return $this->queryBuilder
->setMaxResults($length)
->setFirstResult($offset)
->getQuery()
->getResult();
}
}
Wow, very clean. I really appreciate you posting your complete solutions inside here - it will definitely help others :)
Thanks!
// composer.json
{
"require": {
"php": ">=5.3.3",
"symfony/symfony": "2.6.*", // v2.6.11
"doctrine/orm": "~2.2,>=2.2.3,<2.5", // v2.4.7
"doctrine/dbal": "<2.5", // v2.4.4
"doctrine/doctrine-bundle": "~1.2", // v1.4.0
"twig/extensions": "~1.0", // v1.2.0
"symfony/assetic-bundle": "~2.3", // v2.6.1
"symfony/swiftmailer-bundle": "~2.3", // v2.3.8
"symfony/monolog-bundle": "~2.4", // v2.7.1
"sensio/distribution-bundle": "~3.0,>=3.0.12", // v3.0.21
"sensio/framework-extra-bundle": "~3.0,>=3.0.2", // v3.0.7
"incenteev/composer-parameter-handler": "~2.0", // v2.1.0
"hautelook/alice-bundle": "0.2.*", // 0.2
"jms/serializer-bundle": "0.13.*", // 0.13.0
"white-october/pagerfanta-bundle": "^1.0" // v1.2.4
},
"require-dev": {
"sensio/generator-bundle": "~2.3", // v2.5.3
"behat/behat": "~3.0", // v3.0.15
"behat/mink-extension": "~2.0.1", // v2.0.1
"behat/mink-goutte-driver": "~1.1.0", // v1.1.0
"behat/mink-selenium2-driver": "~1.2.0", // v1.2.0
"phpunit/phpunit": "~4.6.0" // 4.6.4
}
}
Why use that $createLinkUrl function instead of creating private function?