Skip to content

Commit d531495

Browse files
authored
Merge pull request #2532 from soyuka/sroze-cursor-pagination
Enable the pagination via cursor (no page-based pagination)
2 parents c80d493 + f1e80f4 commit d531495

File tree

9 files changed

+283
-10
lines changed

9 files changed

+283
-10
lines changed

features/bootstrap/DoctrineContext.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\RelatedToDummyFriend as RelatedToDummyFriendDocument;
4848
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\RelationEmbedder as RelationEmbedderDocument;
4949
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\SecuredDummy as SecuredDummyDocument;
50+
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\SoMany as SoManyDocument;
5051
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\ThirdLevel as ThirdLevelDocument;
5152
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\User as UserDocument;
5253
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Answer;
@@ -89,6 +90,7 @@
8990
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedToDummyFriend;
9091
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelationEmbedder;
9192
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\SecuredDummy;
93+
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\SoMany;
9294
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\ThirdLevel;
9395
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\User;
9496
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\UuidIdentifierDummy;
@@ -159,6 +161,21 @@ public function thereAreDummyObjects(int $nb)
159161
$this->manager->flush();
160162
}
161163

164+
/**
165+
* @Given there are :nb of these so many objects
166+
*/
167+
public function thereAreOfTheseSoManyObjects(int $nb)
168+
{
169+
for ($i = 1; $i <= $nb; ++$i) {
170+
$dummy = $this->isOrm() ? new SoMany() : new SoManyDocument();
171+
$dummy->content = 'Many #'.$i;
172+
173+
$this->manager->persist($dummy);
174+
}
175+
176+
$this->manager->flush();
177+
}
178+
162179
/**
163180
* @Given there are :nb foo objects with fake names
164181
*/

features/hydra/collection.feature

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,3 +437,109 @@ Feature: Collections support
437437
When I send a "GET" request to "/dummies?itemsPerPage=0&page=2"
438438
Then the response status code should be 400
439439
And the JSON node "hydra:description" should be equal to "Page should not be greater than 1 if limit is equal to 0"
440+
441+
Scenario: Cursor-based pagination with an empty collection
442+
When I send a "GET" request to "/so_manies"
443+
Then the response status code should be 200
444+
And the response should be in JSON
445+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
446+
And the JSON should be valid according to this schema:
447+
"""
448+
{
449+
"type": "object",
450+
"properties": {
451+
"@context": {"pattern": "^/contexts/SoMany$"},
452+
"@id": {"pattern": "^/so_manies$"},
453+
"@type": {"pattern": "^hydra:Collection"},
454+
"hydra:view": {
455+
"type": "object",
456+
"properties": {
457+
"@id": {"pattern": "^/so_manies$"},
458+
"@type": {"pattern": "^hydra:PartialCollectionView$"}
459+
},
460+
"additionalProperties": false
461+
},
462+
"hydra:member": {
463+
"type": "array"
464+
}
465+
}
466+
}
467+
"""
468+
469+
@createSchema
470+
Scenario: Cursor-based pagination with ranged items
471+
Given there are 10 of these so many objects
472+
When I send a "GET" request to "/so_manies?order[id]=desc"
473+
Then the response status code should be 200
474+
And the response should be in JSON
475+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
476+
And the JSON should be valid according to this schema:
477+
"""
478+
{
479+
"type": "object",
480+
"properties": {
481+
"@context": {"pattern": "^/contexts/SoMany$"},
482+
"@id": {"pattern": "^/so_manies$"},
483+
"@type": {"pattern": "^hydra:Collection"},
484+
"hydra:view": {
485+
"type": "object",
486+
"properties": {
487+
"@id": {"pattern": "^/so_manies\\?order%5Bid%5D=desc$"},
488+
"@type": {"pattern": "^hydra:PartialCollectionView$"},
489+
"hydra:previous": {"pattern": "^/so_manies\\?order%5Bid%5D=desc&id%5Bgt%5D=10$"},
490+
"hydra:next": {"pattern": "^/so_manies\\?order%5Bid%5D=desc&id%5Blt%5D=8$"}
491+
},
492+
"additionalProperties": false
493+
},
494+
"hydra:member": {
495+
"type": "array",
496+
"items": {
497+
"type": "object",
498+
"properties": {
499+
"@id": {
500+
"oneOf": [
501+
{"pattern": "^/so_manies/8$"},
502+
{"pattern": "^/so_manies/9$"},
503+
{"pattern": "^/so_manies/10$"}
504+
]
505+
}
506+
}
507+
},
508+
"minItems": 3
509+
}
510+
}
511+
}
512+
"""
513+
514+
@createSchema
515+
Scenario: Cursor-based pagination with range filtered items
516+
Given there are 10 of these so many objects
517+
When I send a "GET" request to "/so_manies?order[id]=desc&id[gt]=10"
518+
Then the response status code should be 200
519+
And the response should be in JSON
520+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
521+
And the JSON should be valid according to this schema:
522+
"""
523+
{
524+
"type": "object",
525+
"properties": {
526+
"@context": {"pattern": "^/contexts/SoMany$"},
527+
"@id": {"pattern": "^/so_manies$"},
528+
"@type": {"pattern": "^hydra:Collection"},
529+
"hydra:member": {
530+
"type": "array",
531+
"maxItems": 0
532+
},
533+
"hydra:view": {
534+
"type": "object",
535+
"properties": {
536+
"@id": {"pattern": "^/so_manies\\?order%5Bid%5D=desc&id%5Bgt%5D=10$"},
537+
"@type": {"pattern": "^hydra:PartialCollectionView$"},
538+
"hydra:previous": {"pattern": "^/so_manies\\?order%5Bid%5D=desc&id%5Bgt%5D=13$"},
539+
"hydra:next": {"pattern": "^/so_manies\\?order%5Bid%5D=desc&id%5Blt%5D=10$"}
540+
},
541+
"additionalProperties": false
542+
}
543+
}
544+
}
545+
"""

src/Annotation/ApiResource.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
* @Attribute("paginationFetchJoinCollection", type="bool"),
5555
* @Attribute("paginationItemsPerPage", type="int"),
5656
* @Attribute("paginationPartial", type="bool"),
57+
* @Attribute("paginationViaCursor", type="array"),
5758
* @Attribute("routePrefix", type="string"),
5859
* @Attribute("shortName", type="string"),
5960
* @Attribute("subresourceOperations", type="array"),
@@ -304,6 +305,13 @@ final class ApiResource
304305
*/
305306
private $openapiContext;
306307

308+
/**
309+
* @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112
310+
*
311+
* @var array
312+
*/
313+
private $paginationViaCursor;
314+
307315
/**
308316
* @throws InvalidArgumentException
309317
*/

src/Bridge/Symfony/Bundle/Resources/config/hydra.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@
6464
<argument type="service" id="api_platform.hydra.normalizer.partial_collection_view.inner" />
6565
<argument>%api_platform.collection.pagination.page_parameter_name%</argument>
6666
<argument>%api_platform.collection.pagination.enabled_parameter_name%</argument>
67+
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
68+
<argument type="service" id="api_platform.property_accessor" />
6769
</service>
6870

6971
<service id="api_platform.hydra.normalizer.collection_filters" class="ApiPlatform\Core\Hydra\Serializer\CollectionFiltersNormalizer" decorates="api_platform.hydra.normalizer.collection" public="false">

src/Hydra/Serializer/PartialCollectionViewNormalizer.php

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@
1515

1616
use ApiPlatform\Core\DataProvider\PaginatorInterface;
1717
use ApiPlatform\Core\DataProvider\PartialPaginatorInterface;
18+
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
1819
use ApiPlatform\Core\Util\IriHelper;
20+
use Symfony\Component\PropertyAccess\PropertyAccess;
21+
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
1922
use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface;
2023
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
2124
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
@@ -31,12 +34,16 @@ final class PartialCollectionViewNormalizer implements NormalizerInterface, Norm
3134
private $collectionNormalizer;
3235
private $pageParameterName;
3336
private $enabledParameterName;
37+
private $resourceMetadataFactory;
38+
private $propertyAccessor;
3439

35-
public function __construct(NormalizerInterface $collectionNormalizer, string $pageParameterName = 'page', string $enabledParameterName = 'pagination')
40+
public function __construct(NormalizerInterface $collectionNormalizer, string $pageParameterName = 'page', string $enabledParameterName = 'pagination', ResourceMetadataFactoryInterface $resourceMetadataFactory = null, PropertyAccessorInterface $propertyAccessor = null)
3641
{
3742
$this->collectionNormalizer = $collectionNormalizer;
3843
$this->pageParameterName = $pageParameterName;
3944
$this->enabledParameterName = $enabledParameterName;
45+
$this->resourceMetadataFactory = $resourceMetadataFactory;
46+
$this->propertyAccessor = $propertyAccessor ?? PropertyAccess::createPropertyAccessor();
4047
}
4148

4249
/**
@@ -69,12 +76,29 @@ public function normalize($object, $format = null, array $context = [])
6976
return $data;
7077
}
7178

79+
$metadata = isset($context['resource_class']) && null !== $this->resourceMetadataFactory ? $this->resourceMetadataFactory->create($context['resource_class']) : null;
80+
$isPaginatedWithCursor = $paginated && null !== $metadata && null !== $cursorPaginationAttribute = $metadata->getCollectionOperationAttribute($context['collection_operation_name'], 'pagination_via_cursor', null, true);
81+
7282
$data['hydra:view'] = [
73-
'@id' => IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $paginated ? $currentPage : null),
83+
'@id' => IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $paginated && !$isPaginatedWithCursor ? $currentPage : null),
7484
'@type' => 'hydra:PartialCollectionView',
7585
];
7686

77-
if ($paginated) {
87+
if ($isPaginatedWithCursor) {
88+
$objects = iterator_to_array($object);
89+
$firstObject = current($objects);
90+
$lastObject = end($objects);
91+
92+
$data['hydra:view']['@id'] = IriHelper::createIri($parsed['parts'], $parsed['parameters']);
93+
94+
if (false !== $lastObject) {
95+
$data['hydra:view']['hydra:next'] = IriHelper::createIri($parsed['parts'], array_merge($parsed['parameters'], $this->cursorPaginationFields($cursorPaginationAttribute, 1, $lastObject)));
96+
}
97+
98+
if (false !== $firstObject) {
99+
$data['hydra:view']['hydra:previous'] = IriHelper::createIri($parsed['parts'], array_merge($parsed['parameters'], $this->cursorPaginationFields($cursorPaginationAttribute, -1, $firstObject)));
100+
}
101+
} elseif ($paginated) {
78102
if (null !== $lastPage) {
79103
$data['hydra:view']['hydra:first'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, 1.);
80104
$data['hydra:view']['hydra:last'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $lastPage);
@@ -117,4 +141,22 @@ public function setNormalizer(NormalizerInterface $normalizer)
117141
$this->collectionNormalizer->setNormalizer($normalizer);
118142
}
119143
}
144+
145+
private function cursorPaginationFields(array $fields, int $direction, $object)
146+
{
147+
$paginationFilters = [];
148+
149+
foreach ($fields as $field) {
150+
$forwardRangeOperator = 'desc' === strtolower($field['direction']) ? 'lt' : 'gt';
151+
$backwardRangeOperator = 'gt' === $forwardRangeOperator ? 'lt' : 'gt';
152+
153+
$operator = $direction > 0 ? $forwardRangeOperator : $backwardRangeOperator;
154+
155+
$paginationFilters[$field['field']] = [
156+
$operator => (string) $this->propertyAccessor->getValue($object, $field['field']),
157+
];
158+
}
159+
160+
return $paginationFilters;
161+
}
120162
}

src/Util/IriHelper.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,9 @@ public static function parseIri(string $iri, string $pageParameterName): array
5656
*
5757
* @param float $page
5858
*/
59-
public static function createIri(array $parts, array $parameters, string $pageParameterName, float $page = null, bool $absoluteUrl = false): string
59+
public static function createIri(array $parts, array $parameters, string $pageParameterName = null, float $page = null, bool $absoluteUrl = false): string
6060
{
61-
if (null !== $page) {
61+
if (null !== $page && null !== $pageParameterName) {
6262
$parameters[$pageParameterName] = $page;
6363
}
6464

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Document;
15+
16+
use ApiPlatform\Core\Annotation\ApiFilter;
17+
use ApiPlatform\Core\Annotation\ApiResource;
18+
use ApiPlatform\Core\Bridge\Doctrine\MongoDbOdm\Filter\OrderFilter;
19+
use ApiPlatform\Core\Bridge\Doctrine\MongoDbOdm\Filter\RangeFilter;
20+
use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
21+
22+
/**
23+
* @ODM\Document
24+
* @ApiResource(attributes={
25+
* "pagination_partial"=true,
26+
* "pagination_via_cursor"={
27+
* {"field"="id", "direction"="DESC"}
28+
* }
29+
* })
30+
*
31+
* @ApiFilter(RangeFilter::class, properties={"id"})
32+
* @ApiFilter(OrderFilter::class, properties={"id"="DESC"})
33+
*/
34+
class SoMany
35+
{
36+
/**
37+
* @ODM\Id(strategy="INCREMENT", type="integer")
38+
*/
39+
public $id;
40+
41+
/**
42+
* @ODM\Field(nullable=true)
43+
*/
44+
public $content;
45+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity;
15+
16+
use ApiPlatform\Core\Annotation\ApiFilter;
17+
use ApiPlatform\Core\Annotation\ApiResource;
18+
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\OrderFilter;
19+
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\RangeFilter;
20+
use Doctrine\ORM\Mapping as ORM;
21+
22+
/**
23+
* @ORM\Entity
24+
* @ApiResource(attributes={
25+
* "pagination_partial"=true,
26+
* "pagination_via_cursor"={
27+
* {"field"="id", "direction"="DESC"}
28+
* }
29+
* })
30+
*
31+
* @ApiFilter(RangeFilter::class, properties={"id"})
32+
* @ApiFilter(OrderFilter::class, properties={"id"="DESC"})
33+
*/
34+
class SoMany
35+
{
36+
/**
37+
* @ORM\Id
38+
* @ORM\Column(type="integer")
39+
* @ORM\GeneratedValue(strategy="AUTO")
40+
*/
41+
public $id;
42+
43+
/**
44+
* @ORM\Column(nullable=true)
45+
*/
46+
public $content;
47+
}

0 commit comments

Comments
 (0)