From ad606caaf7965c188d07b68c3fac0fc5a450929e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillaume=20Delr=C3=A9?= Date: Fri, 8 May 2026 18:10:46 +0200 Subject: [PATCH 1/2] fix(hydra): emit hydra:next and hydra:previous on empty cursor-paginated collections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a cursor-paginated collection returns no items and the request URL already contains a cursor filter (e.g. id[gt]=10), navigation links were silently omitted because populateDataWithCursorBasedPagination() relies on reading the first and last objects of the collection. Synthesize the links from the URL parameters instead: - hydra:next: invert the cursor operator (gt -> lt), keep the value - hydra:previous: keep the operator, shift the value by items_per_page If no cursor filter is present in the URL, the behavior is unchanged. Fixes #7953 Signed-off-by: Guillaume Delré --- .../PartialCollectionViewNormalizer.php | 53 ++++++++++++ .../PartialCollectionViewNormalizerTest.php | 83 +++++++++++++++++++ .../TestBundle/Entity/Issue7953/Dummy.php | 37 +++++++++ .../CursorPaginationEmptyCollectionTest.php | 78 +++++++++++++++++ 4 files changed, 251 insertions(+) create mode 100644 tests/Fixtures/TestBundle/Entity/Issue7953/Dummy.php create mode 100644 tests/Functional/CursorPaginationEmptyCollectionTest.php diff --git a/src/Hydra/Serializer/PartialCollectionViewNormalizer.php b/src/Hydra/Serializer/PartialCollectionViewNormalizer.php index 4995e0e2673..51ecf50bb80 100644 --- a/src/Hydra/Serializer/PartialCollectionViewNormalizer.php +++ b/src/Hydra/Serializer/PartialCollectionViewNormalizer.php @@ -181,6 +181,59 @@ private function populateDataWithCursorBasedPagination(array $data, array $parse $data[$hydraPrefix.'view'][$hydraPrefix.'previous'] = IriHelper::createIri($parsed['parts'], array_merge($parsed['parameters'], $this->cursorPaginationFields($cursorPaginationAttribute, -1, $firstObject)), urlGenerationStrategy: $urlGenerationStrategy); } + if (false === $firstObject && \is_array($cursorPaginationAttribute) && $object instanceof PartialPaginatorInterface) { + $itemsPerPage = $object->getItemsPerPage(); + $nextFilters = $this->cursorPaginationFieldsFromUrl($cursorPaginationAttribute, $parsed['parameters'], $itemsPerPage, 1); + $previousFilters = $this->cursorPaginationFieldsFromUrl($cursorPaginationAttribute, $parsed['parameters'], $itemsPerPage, -1); + + if (null !== $nextFilters) { + $data[$hydraPrefix.'view'][$hydraPrefix.'next'] = IriHelper::createIri($parsed['parts'], array_merge($parsed['parameters'], $nextFilters), urlGenerationStrategy: $urlGenerationStrategy); + } + + if (null !== $previousFilters) { + $data[$hydraPrefix.'view'][$hydraPrefix.'previous'] = IriHelper::createIri($parsed['parts'], array_merge($parsed['parameters'], $previousFilters), urlGenerationStrategy: $urlGenerationStrategy); + } + } + return $data; } + + private function cursorPaginationFieldsFromUrl(array $fields, array $parameters, float $itemsPerPage, int $direction): ?array + { + $paginationFilters = []; + + foreach ($fields as $field) { + $forwardOperator = 'desc' === strtolower($field['direction']) ? 'lt' : 'gt'; + $backwardOperator = 'gt' === $forwardOperator ? 'lt' : 'gt'; + + if (!\is_array($parameters[$field['field']] ?? null)) { + return null; + } + + $currentOperator = (string) array_key_first($parameters[$field['field']]); + if (!\in_array($currentOperator, ['gt', 'lt'], true)) { + return null; + } + + $currentValue = (float) current($parameters[$field['field']]); + + if ($direction > 0) { + if ($currentOperator === $forwardOperator) { + $newValue = 'gt' === $currentOperator ? $currentValue + $itemsPerPage : $currentValue - $itemsPerPage; + $paginationFilters[$field['field']] = [$currentOperator => (string) $newValue]; + } else { + $paginationFilters[$field['field']] = [$forwardOperator => (string) $currentValue]; + } + } else { + if ($currentOperator === $backwardOperator) { + $newValue = 'gt' === $currentOperator ? $currentValue + $itemsPerPage : $currentValue - $itemsPerPage; + $paginationFilters[$field['field']] = [$currentOperator => (string) $newValue]; + } else { + $paginationFilters[$field['field']] = [$backwardOperator => (string) $currentValue]; + } + } + } + + return $paginationFilters; + } } diff --git a/src/Hydra/Tests/Serializer/PartialCollectionViewNormalizerTest.php b/src/Hydra/Tests/Serializer/PartialCollectionViewNormalizerTest.php index 49b7a194a16..2a09190fabd 100644 --- a/src/Hydra/Tests/Serializer/PartialCollectionViewNormalizerTest.php +++ b/src/Hydra/Tests/Serializer/PartialCollectionViewNormalizerTest.php @@ -108,6 +108,89 @@ public function testNormalizeWithCursorBasedPagination(): void ); } + public function testNormalizeWithCursorBasedPaginationEmptyCollection(): void + { + $paginator = new class implements PartialPaginatorInterface, \Iterator { + public function rewind(): void + { + } + + public function valid(): bool + { + return false; + } + + public function current(): mixed + { + return false; + } + + public function key(): mixed + { + return null; + } + + public function next(): void + { + } + + public function count(): int + { + return 0; + } + + public function getCurrentPage(): float + { + return 1.; + } + + public function getItemsPerPage(): float + { + return 3.; + } + }; + + $decoratedNormalizer = $this->createStub(NormalizerInterface::class); + $decoratedNormalizer->method('normalize')->willReturn(['foo' => 'bar']); + + $soManyMetadata = new ResourceMetadataCollection(SoMany::class, [ + (new ApiResource())->withShortName('SoMany')->withOperations(new Operations([ + 'get' => (new GetCollection())->withPaginationViaCursor([['field' => 'id', 'direction' => 'desc']]), + ])), + ]); + + $resourceMetadataFactory = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactory->method('create')->willReturn($soManyMetadata); + + $normalizer = new PartialCollectionViewNormalizer( + $decoratedNormalizer, + '_page', + 'pagination', + $resourceMetadataFactory + ); + + self::assertEquals( + [ + 'foo' => 'bar', + 'hydra:view' => [ + '@id' => '/?id%5Bgt%5D=10', + '@type' => 'hydra:PartialCollectionView', + 'hydra:next' => '/?id%5Blt%5D=10', + 'hydra:previous' => '/?id%5Bgt%5D=13', + ], + ], + $normalizer->normalize( + $paginator, + null, + [ + 'resource_class' => SoMany::class, + 'operation_name' => 'get', + 'request_uri' => '/?id%5Bgt%5D=10', + ] + ) + ); + } + private function normalizePaginator(bool $partial = false, bool $cursor = false) { $paginatorProphecy = $this->prophesize($partial ? PartialPaginatorInterface::class : PaginatorInterface::class); diff --git a/tests/Fixtures/TestBundle/Entity/Issue7953/Dummy.php b/tests/Fixtures/TestBundle/Entity/Issue7953/Dummy.php new file mode 100644 index 00000000000..cda0a0f6e6b --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Issue7953/Dummy.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue7953; + +use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; +use ApiPlatform\Doctrine\Orm\Filter\RangeFilter; +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +#[ApiFilter(RangeFilter::class, properties: ['id'])] +#[ApiFilter(OrderFilter::class, properties: ['id' => 'DESC'])] +#[ApiResource( + shortName: 'Issue7953', + paginationItemsPerPage: 3, + paginationPartial: true, + paginationViaCursor: [['field' => 'id', 'direction' => 'DESC']], +)] +#[ORM\Entity] +class Dummy +{ + #[ORM\Id] + #[ORM\Column] + #[ORM\GeneratedValue] + public ?int $id = null; +} diff --git a/tests/Functional/CursorPaginationEmptyCollectionTest.php b/tests/Functional/CursorPaginationEmptyCollectionTest.php new file mode 100644 index 00000000000..354ab8903a8 --- /dev/null +++ b/tests/Functional/CursorPaginationEmptyCollectionTest.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue7953\Dummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * Regression test for https://github.com/api-platform/core/issues/7953. + * + * An empty cursor-paginated collection must still emit hydra:next and hydra:previous + * when the request URL contains a cursor filter parameter. + */ +final class CursorPaginationEmptyCollectionTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [Dummy::class]; + } + + protected function setUp(): void + { + parent::setUp(); + + if ($this->isMongoDB()) { + $this->markTestSkipped('Not tested with mongodb.'); + } + + $this->recreateSchema(static::getResources()); + + $manager = $this->getManager(); + for ($i = 0; $i < 10; ++$i) { + $manager->persist(new Dummy()); + } + $manager->flush(); + } + + public function testEmptyCollectionWithCursorFilterHasNavigationLinks(): void + { + // id[gt]=10 matches nothing (max id is 10), so the collection is empty + $response = self::createClient()->request('GET', '/issue7953s?id[gt]=10&order[id]=desc', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + + $this->assertEmpty($data['hydra:member'], 'Collection must be empty for this test to be meaningful'); + $this->assertArrayHasKey('hydra:view', $data); + + // Both navigation links must be present even on empty collection + $this->assertArrayHasKey('hydra:next', $data['hydra:view']); + $this->assertArrayHasKey('hydra:previous', $data['hydra:view']); + + // hydra:next: inverted operator (gt -> lt), same cursor value + $this->assertStringContainsString('id%5Blt%5D=10', $data['hydra:view']['hydra:next']); + // hydra:previous: same operator (gt), value shifted by items_per_page (10 + 3 = 13) + $this->assertStringContainsString('id%5Bgt%5D=13', $data['hydra:view']['hydra:previous']); + } +} From a8983610c479d1af7bc9615b77ae64c14f06a703 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 11 May 2026 11:32:22 +0200 Subject: [PATCH 2/2] test(hydra): add cursor pagination empty collection regression test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | Q | A | ------------- | --- | Branch? | 4.3 | Tickets | refs #7953 | License | MIT | Doc PR | ∅ Adds CursorPaginatedDummy fixture (canonical QueryParameter form with ComparisonFilter + SortFilter) and a phpunit functional test asserting hydra:next / hydra:previous on empty cursor-paginated collections. graphQlOperations is disabled on the fixture: a latent bug in FieldsBuilder.php:515 (ServiceLocator::has called with a FilterInterface instance instead of a service id) crashes the GraphQL schema build when an object-form filter is combined with a bracketed parameter key. --- .../Entity/CursorPaginatedDummy.php | 50 +++++++++++++++++++ .../TestBundle/Entity/Issue7953/Dummy.php | 37 -------------- .../CursorPaginationEmptyCollectionTest.php | 8 +-- 3 files changed, 54 insertions(+), 41 deletions(-) create mode 100644 tests/Fixtures/TestBundle/Entity/CursorPaginatedDummy.php delete mode 100644 tests/Fixtures/TestBundle/Entity/Issue7953/Dummy.php diff --git a/tests/Fixtures/TestBundle/Entity/CursorPaginatedDummy.php b/tests/Fixtures/TestBundle/Entity/CursorPaginatedDummy.php new file mode 100644 index 00000000000..b914ca61407 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/CursorPaginatedDummy.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Doctrine\Orm\Filter\ComparisonFilter; +use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; +use ApiPlatform\Doctrine\Orm\Filter\SortFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +#[ApiResource( + paginationItemsPerPage: 3, + paginationPartial: true, + paginationViaCursor: [['field' => 'id', 'direction' => 'DESC']], + graphQlOperations: [], + parameters: [ + 'id' => new QueryParameter( + filter: new ComparisonFilter(new ExactFilter()), + property: 'id', + nativeType: new BuiltinType(TypeIdentifier::INT), + ), + 'order[id]' => new QueryParameter( + filter: new SortFilter(), + property: 'id', + nativeType: new BuiltinType(TypeIdentifier::INT), + ), + ], +)] +#[ORM\Entity] +class CursorPaginatedDummy +{ + #[ORM\Id] + #[ORM\Column] + #[ORM\GeneratedValue] + public ?int $id = null; +} diff --git a/tests/Fixtures/TestBundle/Entity/Issue7953/Dummy.php b/tests/Fixtures/TestBundle/Entity/Issue7953/Dummy.php deleted file mode 100644 index cda0a0f6e6b..00000000000 --- a/tests/Fixtures/TestBundle/Entity/Issue7953/Dummy.php +++ /dev/null @@ -1,37 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue7953; - -use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; -use ApiPlatform\Doctrine\Orm\Filter\RangeFilter; -use ApiPlatform\Metadata\ApiFilter; -use ApiPlatform\Metadata\ApiResource; -use Doctrine\ORM\Mapping as ORM; - -#[ApiFilter(RangeFilter::class, properties: ['id'])] -#[ApiFilter(OrderFilter::class, properties: ['id' => 'DESC'])] -#[ApiResource( - shortName: 'Issue7953', - paginationItemsPerPage: 3, - paginationPartial: true, - paginationViaCursor: [['field' => 'id', 'direction' => 'DESC']], -)] -#[ORM\Entity] -class Dummy -{ - #[ORM\Id] - #[ORM\Column] - #[ORM\GeneratedValue] - public ?int $id = null; -} diff --git a/tests/Functional/CursorPaginationEmptyCollectionTest.php b/tests/Functional/CursorPaginationEmptyCollectionTest.php index 354ab8903a8..c1a45034165 100644 --- a/tests/Functional/CursorPaginationEmptyCollectionTest.php +++ b/tests/Functional/CursorPaginationEmptyCollectionTest.php @@ -14,7 +14,7 @@ namespace ApiPlatform\Tests\Functional; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue7953\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CursorPaginatedDummy; use ApiPlatform\Tests\RecreateSchemaTrait; use ApiPlatform\Tests\SetupClassResourcesTrait; @@ -33,7 +33,7 @@ final class CursorPaginationEmptyCollectionTest extends ApiTestCase public static function getResources(): array { - return [Dummy::class]; + return [CursorPaginatedDummy::class]; } protected function setUp(): void @@ -48,7 +48,7 @@ protected function setUp(): void $manager = $this->getManager(); for ($i = 0; $i < 10; ++$i) { - $manager->persist(new Dummy()); + $manager->persist(new CursorPaginatedDummy()); } $manager->flush(); } @@ -56,7 +56,7 @@ protected function setUp(): void public function testEmptyCollectionWithCursorFilterHasNavigationLinks(): void { // id[gt]=10 matches nothing (max id is 10), so the collection is empty - $response = self::createClient()->request('GET', '/issue7953s?id[gt]=10&order[id]=desc', [ + $response = self::createClient()->request('GET', '/cursor_paginated_dummies?id[gt]=10&order[id]=desc', [ 'headers' => ['Accept' => 'application/ld+json'], ]);