diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a05ad2d1cb0..2f69b50407e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -258,7 +258,7 @@ jobs: vendor/bin/behat --out=std --format=progress --format=junit --out=build/logs/behat/junit --profile=default-coverage --no-interaction --tags='~@php8' else if [ "${{ matrix.php }}" = '7.1' ]; then - vendor/bin/behat --out=std --format=progress --format=junit --out=build/logs/behat/junit --profile=default --no-interaction --tags='~@symfony/uid&&~@php8' + vendor/bin/behat --out=std --format=progress --format=junit --out=build/logs/behat/junit --profile=default --no-interaction --tags='~@symfony/uid&&~@php8&&~@uuid/v6' else vendor/bin/behat --out=std --format=progress --format=junit --out=build/logs/behat/junit --profile=default --no-interaction --tags='~@php8' fi @@ -408,7 +408,7 @@ jobs: run: tests/Fixtures/app/console cache:clear --ansi - name: Run Behat tests # @TODO remove the tag "@symfony/uid" in 3.0 - run: vendor/bin/behat --out=std --format=progress --profile=default --no-interaction --tags='~@symfony/uid&&~php8' + run: vendor/bin/behat --out=std --format=progress --profile=default --no-interaction --tags='~@symfony/uid&&~php8&&~@uuid/v6' postgresql: name: Behat (PHP ${{ matrix.php }}) (PostgreSQL) @@ -564,9 +564,9 @@ jobs: run: | mkdir -p build/logs/behat if [ "$COVERAGE" = '1' ]; then - vendor/bin/behat --out=std --format=progress --format=junit --out=build/logs/behat/junit --profile=mongodb-coverage --no-interaction --tags='~@php8' + vendor/bin/behat --out=std --format=progress --format=junit --out=build/logs/behat/junit --profile=mongodb-coverage --no-interaction --tags='~@php8&&~@uuid/v6' else - vendor/bin/behat --out=std --format=progress --format=junit --out=build/logs/behat/junit --profile=mongodb --no-interaction --tags='~@php8' + vendor/bin/behat --out=std --format=progress --format=junit --out=build/logs/behat/junit --profile=mongodb --no-interaction --tags='~@php8&&~@uuid/v6' fi - name: Merge code coverage reports run: | @@ -919,7 +919,7 @@ jobs: - name: Run Behat tests run: | mkdir -p build/logs/behat - vendor/bin/behat --out=std --format=progress --format=junit --out=build/logs/behat/junit --profile=mongodb --no-interaction + vendor/bin/behat --out=std --format=progress --format=junit --out=build/logs/behat/junit --profile=mongodb --no-interaction --tags='~@uuid/v6' - name: Upload test artifacts if: always() uses: actions/upload-artifact@v1 diff --git a/features/hydra/collection.feature b/features/hydra/collection.feature index aebca7165d7..ee6e8edb864 100644 --- a/features/hydra/collection.feature +++ b/features/hydra/collection.feature @@ -514,6 +514,97 @@ Feature: Collections support } """ + @createSchema + Scenario: Cursor-based pagination with ranged items and set cursor + Given there are 10 of these so many objects + When I send a "GET" request to "/so_manies?order%5Bid%5D=desc&id%5Blt%5D=7" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "properties": { + "@context": {"pattern": "^/contexts/SoMany$"}, + "@id": {"pattern": "^/so_manies$"}, + "@type": {"pattern": "^hydra:Collection"}, + "hydra:view": { + "type": "object", + "properties": { + "@id": {"pattern": "^/so_manies\\?order%5Bid%5D=desc&id%5Blt%5D=7$"}, + "@type": {"pattern": "^hydra:PartialCollectionView$"}, + "hydra:previous": {"pattern": "^/so_manies\\?order%5Bid%5D=desc&id%5Bgt%5D=6$"}, + "hydra:next": {"pattern": "^/so_manies\\?order%5Bid%5D=desc&id%5Blt%5D=4$"} + }, + "additionalProperties": false + }, + "hydra:member": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@id": { + "oneOf": [ + {"pattern": "^/so_manies/6$"}, + {"pattern": "^/so_manies/5$"}, + {"pattern": "^/so_manies/4$"} + ] + } + } + }, + "minItems": 3 + } + } + } + """ + + @createSchema + @uuid/v6 + Scenario: Cursor-based pagination with ranged items on uids + Given there are 10 of these so many uid objects + When I send a "GET" request to "/so_many_uids?order%5Bid%5D=desc&id%5Blt%5D=1ec5c128-f3d4-62d0-b528-68fef707f0bd" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "properties": { + "@context": {"pattern": "^/contexts/SoManyUids"}, + "@id": {"pattern": "^/so_many_uids"}, + "@type": {"pattern": "^hydra:Collection"}, + "hydra:view": { + "type": "object", + "properties": { + "@id": {"pattern": "^/so_many_uids\\?order%5Bid%5D=desc&id%5Blt%5D=1ec5c128-f3d4-62d0-b528-68fef707f0bd$"}, + "@type": {"pattern": "^hydra:PartialCollectionView$"}, + "hydra:previous": {"pattern": "^/so_many_uids\\?order%5Bid%5D=desc&id%5Bgt%5D=1ec5c128-f3d4-61ae-bb3c-68fef707f0bd$"}, + "hydra:next": {"pattern": "^/so_many_uids\\?order%5Bid%5D=desc&id%5Blt%5D=1ec5c128-f3d3-6fc4-8b52-68fef707f0bd$"} + }, + "additionalProperties": false + }, + "hydra:member": { + "type": "array", + "items": { + "type": "object", + "properties": { + "content": { + "oneOf": [ + {"pattern": "^Many #7$"}, + {"pattern": "^Many #6$"}, + {"pattern": "^Many #5$"} + ] + } + } + }, + "minItems": 3 + } + } + } + """ + @createSchema Scenario: Cursor-based pagination with range filtered items Given there are 10 of these so many objects diff --git a/src/Doctrine/Common/Filter/RangeFilterTrait.php b/src/Doctrine/Common/Filter/RangeFilterTrait.php index 6051ac48efd..9b82bfef394 100644 --- a/src/Doctrine/Common/Filter/RangeFilterTrait.php +++ b/src/Doctrine/Common/Filter/RangeFilterTrait.php @@ -124,18 +124,27 @@ private function normalizeBetweenValues(array $values): ?array /** * Normalize the value. * - * @return int|float|null + * @return int|float|string|null */ private function normalizeValue(string $value, string $operator) { - if (!is_numeric($value)) { + if (!is_numeric($value) && !$this->isValidUid($value)) { $this->getLogger()->notice('Invalid filter ignored', [ - 'exception' => new InvalidArgumentException(sprintf('Invalid value for "[%s]", expected number', $operator)), + 'exception' => new InvalidArgumentException(sprintf('Invalid value for "[%s]", expected number or uid', $operator)), ]); return null; } - return $value + 0; // coerce $value to the right type. + if (is_numeric($value)) { + return $value + 0; // coerce $value to the right type. + } + + return $value; + } + + private function isValidUid($potentialUid): bool + { + return \is_string($potentialUid) && preg_match('{^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$}Di', $potentialUid); } } diff --git a/tests/Core/Behat/DoctrineContext.php b/tests/Core/Behat/DoctrineContext.php index 6db1e7d6d85..9896cc773be 100644 --- a/tests/Core/Behat/DoctrineContext.php +++ b/tests/Core/Behat/DoctrineContext.php @@ -159,6 +159,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Site; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SoMany; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SoManyUids; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SymfonyUuidDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Taxon; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; @@ -173,6 +174,7 @@ use Doctrine\ORM\Tools\SchemaTool; use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ObjectManager; +use Ramsey\Uuid\Nonstandard\UuidV6; use Ramsey\Uuid\Uuid; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; @@ -289,6 +291,35 @@ public function thereAreOfTheseSoManyObjects(int $nb) $this->manager->flush(); } + /** + * @Given there are :nb of these so many uid objects + */ + public function thereAreOfTheseSoManyUidObjects(int $nb) + { + $ids = [ + '1ec5c128-f3d2-643a-8b17-68fef707f0bd', // 1 + '1ec5c128-f3d3-6cf4-a77e-68fef707f0bd', + '1ec5c128-f3d3-6e02-8834-68fef707f0bd', + '1ec5c128-f3d3-6ef2-b5f3-68fef707f0bd', + '1ec5c128-f3d3-6fc4-8b52-68fef707f0bd', + '1ec5c128-f3d4-6096-b820-68fef707f0bd', + '1ec5c128-f3d4-61ae-bb3c-68fef707f0bd', // 7 + '1ec5c128-f3d4-62d0-b528-68fef707f0bd', + '1ec5c128-f3d4-63f2-b845-68fef707f0bd', + '1ec5c128-f3d4-6514-8d2b-68fef707f0bd', // 10 + ]; + + for ($i = 1; $i <= $nb; ++$i) { + $ids[] = UuidV6::uuid6()->toString(); + $id = $ids[$i - 1] ?? null; + $dummy = new SoManyUids($id); + $dummy->content = 'Many #'.$i; + + $this->manager->persist($dummy); + } + $this->manager->flush(); + } + /** * @When some dummy table inheritance data but not api resource child are created */ diff --git a/tests/Fixtures/TestBundle/Entity/SoManyUids.php b/tests/Fixtures/TestBundle/Entity/SoManyUids.php new file mode 100644 index 00000000000..95dbca4dcb8 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/SoManyUids.php @@ -0,0 +1,56 @@ + + * + * 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\Core\Annotation\ApiFilter; +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; +use ApiPlatform\Doctrine\Orm\Filter\RangeFilter; +use Doctrine\ORM\Mapping as ORM; +use Ramsey\Uuid\Nonstandard\UuidV6; + +/** + * @ORM\Entity + * @ApiResource(attributes={ + * "pagination_partial"=true, + * "pagination_via_cursor"={ + * {"field"="id", "direction"="DESC"} + * } + * }) + * + * @ApiFilter(RangeFilter::class, properties={"id"}) + * @ApiFilter(OrderFilter::class, properties={"id"="DESC"}) + */ +class SoManyUids +{ + /** + * @ORM\Id + * @ORM\Column(type="uuid") + */ + public $id; + + /** + * @ORM\Column(nullable=true) + */ + public $content; + + public function __construct($id) + { + if ($id) { + $this->id = UuidV6::fromString($id); + } else { + $this->id = UuidV6::uuid6(); + } + } +}