Skip to content

Commit 215c084

Browse files
srozesoyuka
authored andcommitted
Enable the pagination via cursor (no page-based pagination)
Add tests for the SoMany collection Uses the PropertyAccessor service and don't break the BC Allow to use multiple fields Fix cs
1 parent c80d493 commit 215c084

File tree

9 files changed

+265
-11
lines changed

9 files changed

+265
-11
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: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,3 +437,99 @@ 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+
}
461+
}
462+
}
463+
"""
464+
465+
@createSchema
466+
Scenario: Cursor-based pagination with items
467+
Given there are 10 of these so many objects
468+
When I send a "GET" request to "/so_manies?order[id]=desc"
469+
Then the response status code should be 200
470+
And the response should be in JSON
471+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
472+
And the JSON should be valid according to this schema:
473+
"""
474+
{
475+
"type": "object",
476+
"properties": {
477+
"@context": {"pattern": "^/contexts/SoMany$"},
478+
"@id": {"pattern": "^/so_manies$"},
479+
"@type": {"pattern": "^hydra:Collection"},
480+
"hydra:view": {
481+
"type": "object",
482+
"properties": {
483+
"@id": {"pattern": "^/so_manies\\?order%5Bid%5D=desc$"},
484+
"@type": {"pattern": "^hydra:PartialCollectionView$"},
485+
"hydra:previous": {"pattern": "^/so_manies\\?order%5Bid%5D=desc&id%5Bgt%5D=10$"},
486+
"hydra:next": {"pattern": "^/so_manies\\?order%5Bid%5D=desc&id%5Blt%5D=8$"}
487+
}
488+
}
489+
}
490+
}
491+
"""
492+
493+
@createSchema
494+
Scenario: Cursor-based pagination with items
495+
Given there are 10 of these so many objects
496+
When I send a "GET" request to "/so_manies?order[id]=desc&id[gt]=10"
497+
Then the response status code should be 200
498+
And the response should be in JSON
499+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
500+
And the JSON should be valid according to this schema:
501+
"""
502+
{
503+
"type": "object",
504+
"properties": {
505+
"@context": {"pattern": "^/contexts/SoMany$"},
506+
"@id": {"pattern": "^/so_manies$"},
507+
"@type": {"pattern": "^hydra:Collection"},
508+
"hydra:member": {
509+
"type": "array",
510+
"items": {
511+
"type": "object",
512+
"properties": {
513+
"@id": {
514+
"oneOf": [
515+
{"pattern": "^/dummies/13$"},
516+
{"pattern": "^/dummies/12$"},
517+
{"pattern": "^/dummies/11$"}
518+
]
519+
}
520+
}
521+
},
522+
"maxItems": 3
523+
},
524+
"hydra:view": {
525+
"type": "object",
526+
"properties": {
527+
"@id": {"pattern": "^/so_manies\\?order%5Bid%5D=desc&id%5Bgt%5D=10$"},
528+
"@type": {"pattern": "^hydra:PartialCollectionView$"},
529+
"hydra:previous": {"pattern": "^/so_manies\\?order%5Bid%5D=desc&id%5Bgt%5D=13$"},
530+
"hydra:next": {"pattern": "^/so_manies\\?order%5Bid%5D=desc&id%5Blt%5D=10$"}
531+
}
532+
}
533+
}
534+
}
535+
"""

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/Metadata/Resource/Factory/AnnotationResourceFilterMetadataFactory.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ public function create(string $resourceClass): ResourceMetadata
6262
}
6363

6464
$filters = array_keys($this->readFilterAnnotations($reflectionClass, $this->reader));
65-
6665
if (!$filters) {
6766
return $parentResourceMetadata;
6867
}

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 <dunglas@gmail.com>
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 <dunglas@gmail.com>
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)