diff --git a/CHANGELOG.md b/CHANGELOG.md index d895178dbb5..a6ebd1650d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -851,6 +851,7 @@ Doctrine: new interfaces for Filters and Extensions ready, switch to the `ApiPla * Mark the GraphQL subsystem as stable (#4500) * feat(test): add `Client::loginUser()` (#4588) * feat(http_cache): use symfony/http-client instead of guzzlehttp/guzzle, `ApiPlatform\Core\HttpCache\PurgerInterface` is deprecated in favor of `ApiPlatform\HttpCache\PurgerInterface`, new purger that uses PURGE (#4695) +* Add `UuidRangeFilter` to allow cursor based pagination on UUIDs (V1 and V6) ## 2.6.9 diff --git a/features/hydra/collection.feature b/features/hydra/collection.feature index 81b525be81b..3e52a82ba5d 100644 --- a/features/hydra/collection.feature +++ b/features/hydra/collection.feature @@ -554,6 +554,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 + 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=018b7743-c432-76ad-bb16-66151bb60a8a" + Then the response status code should be 200 + Then print last JSON response + 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/SoManyUid"}, + "@id": {"pattern": "^/so_many_uid"}, + "@type": {"pattern": "^hydra:Collection"}, + "hydra:view": { + "type": "object", + "properties": { + "@id": {"pattern": "^/so_many_uids\\?order%5Bid%5D=desc&id%5Blt%5D=018b7743-c432-76ad-bb16-66151bb60a8a$"}, + "@type": {"pattern": "^hydra:PartialCollectionView$"}, + "hydra:previous": {"pattern": "^/so_many_uids\\?order%5Bid%5D=desc&id%5Bgt%5D=018b7743-a62c-7be8-8e4c-67c363fd5a01$"}, + "hydra:next": {"pattern": "^/so_many_uids\\?order%5Bid%5D=desc&id%5Blt%5D=018b7743-6a9f-72af-9587-c7e06f11c33e$"} + }, + "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 6dbadab2e80..3e40252e907 100644 --- a/src/Doctrine/Common/Filter/RangeFilterTrait.php +++ b/src/Doctrine/Common/Filter/RangeFilterTrait.php @@ -18,7 +18,7 @@ use Psr\Log\LoggerInterface; /** - * Trait for filtering the collection by range. + * Trait for filtering the collection by range using numbers. * * @author Lee Siong Chan * @author Alan Poulain @@ -76,7 +76,7 @@ protected function getFilterDescription(string $fieldName, string $operator): ar ]; } - private function normalizeValues(array $values, string $property): ?array + protected function normalizeValues(array $values, string $property): ?array { $operators = [self::PARAMETER_BETWEEN, self::PARAMETER_GREATER_THAN, self::PARAMETER_GREATER_THAN_OR_EQUAL, self::PARAMETER_LESS_THAN, self::PARAMETER_LESS_THAN_OR_EQUAL]; @@ -100,7 +100,7 @@ private function normalizeValues(array $values, string $property): ?array /** * Normalize the values array for between operator. */ - private function normalizeBetweenValues(array $values): ?array + protected function normalizeBetweenValues(array $values): ?array { if (2 !== \count($values)) { $this->getLogger()->notice('Invalid filter ignored', [ @@ -124,7 +124,7 @@ private function normalizeBetweenValues(array $values): ?array /** * Normalize the value. */ - private function normalizeValue(string $value, string $operator): float|int|null + protected function normalizeValue(string $value, string $operator): float|int|null { if (!is_numeric($value)) { $this->getLogger()->notice('Invalid filter ignored', [ diff --git a/src/Doctrine/Common/Filter/UuidRangeFilterTrait.php b/src/Doctrine/Common/Filter/UuidRangeFilterTrait.php new file mode 100644 index 00000000000..2f527de6982 --- /dev/null +++ b/src/Doctrine/Common/Filter/UuidRangeFilterTrait.php @@ -0,0 +1,67 @@ + + * + * 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\Doctrine\Common\Filter; + +use ApiPlatform\Exception\InvalidArgumentException; +use Symfony\Component\Uid\Uuid; + +/** + * Trait for filtering the collection by range using UUIDs (UUID v6). + * + * @author Kai Dederichs + */ +trait UuidRangeFilterTrait +{ + use RangeFilterTrait; + + /** + * {@inheritdoc} + */ + protected function normalizeBetweenValues(array $values): ?array + { + if (2 !== \count($values)) { + $this->getLogger()->notice('Invalid filter ignored', [ + 'exception' => new InvalidArgumentException(sprintf('Invalid format for "[%s]", expected ".."', self::PARAMETER_BETWEEN)), + ]); + + return null; + } + + if (!Uuid::isValid($values[0]) || !Uuid::isValid($values[1])) { + $this->getLogger()->notice('Invalid filter ignored', [ + 'exception' => new InvalidArgumentException(sprintf('Invalid values for "[%s]" range, expected uuids', self::PARAMETER_BETWEEN)), + ]); + + return null; + } + + return [Uuid::fromString($values[0]), Uuid::fromString($values[1])]; + } + + /** + * Normalize the value. + */ + protected function normalizeValue(string $value, string $operator): Uuid | null + { + if (!Uuid::isValid($value)) { + $this->getLogger()->notice('Invalid filter ignored', [ + 'exception' => new InvalidArgumentException(sprintf('Invalid value for "[%s]", expected uuid', $operator)), + ]); + + return null; + } + + return Uuid::fromString($value); + } +} diff --git a/src/Doctrine/Odm/Filter/AbstractRangeFilter.php b/src/Doctrine/Odm/Filter/AbstractRangeFilter.php new file mode 100644 index 00000000000..20e8bf02609 --- /dev/null +++ b/src/Doctrine/Odm/Filter/AbstractRangeFilter.php @@ -0,0 +1,134 @@ + + * + * 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\Doctrine\Odm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\RangeFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ODM\MongoDB\Aggregation\Builder; + + +/** + * Filters the collection by range using numbers. + * + * @experimental + * + * @author Lee Siong Chan + * @author Alan Poulain + * @author Kai Dederichs + */ +abstract class AbstractRangeFilter extends AbstractFilter implements RangeFilterInterface +{ + abstract protected function normalizeValue(string $value, string $operator); + + abstract protected function normalizeBetweenValues(array $values): ?array; + + abstract protected function normalizeValues(array $values, string $property): ?array; + + /** + * {@inheritdoc} + */ + protected function filterProperty(string $property, $values, Builder $aggregationBuilder, string $resourceClass, Operation $operation = null, array &$context = []): void + { + if ( + !\is_array($values) + || !$this->isPropertyEnabled($property, $resourceClass) + || !$this->isPropertyMapped($property, $resourceClass) + ) { + return; + } + + $values = $this->normalizeValues($values, $property); + if (null === $values) { + return; + } + + $matchField = $field = $property; + + if ($this->isPropertyNested($property, $resourceClass)) { + [$matchField] = $this->addLookupsForNestedProperty($property, $aggregationBuilder, $resourceClass); + } + + foreach ($values as $operator => $value) { + $this->addMatch( + $aggregationBuilder, + $field, + $matchField, + $operator, + $value + ); + } + } + + /** + * Adds the match stage according to the operator. + */ + protected function addMatch(Builder $aggregationBuilder, string $field, string $matchField, string $operator, string $value): void + { + switch ($operator) { + case self::PARAMETER_BETWEEN: + $rangeValue = explode('..', $value, 2); + + $rangeValue = $this->normalizeBetweenValues($rangeValue); + if (null === $rangeValue) { + return; + } + + if ($rangeValue[0] === $rangeValue[1]) { + $aggregationBuilder->match()->field($matchField)->equals($rangeValue[0]); + + return; + } + + $aggregationBuilder->match()->field($matchField)->gte($rangeValue[0])->lte($rangeValue[1]); + + break; + case self::PARAMETER_GREATER_THAN: + $value = $this->normalizeValue($value, $operator); + if (null === $value) { + return; + } + + $aggregationBuilder->match()->field($matchField)->gt($value); + + break; + case self::PARAMETER_GREATER_THAN_OR_EQUAL: + $value = $this->normalizeValue($value, $operator); + if (null === $value) { + return; + } + + $aggregationBuilder->match()->field($matchField)->gte($value); + + break; + case self::PARAMETER_LESS_THAN: + $value = $this->normalizeValue($value, $operator); + if (null === $value) { + return; + } + + $aggregationBuilder->match()->field($matchField)->lt($value); + + break; + case self::PARAMETER_LESS_THAN_OR_EQUAL: + $value = $this->normalizeValue($value, $operator); + if (null === $value) { + return; + } + + $aggregationBuilder->match()->field($matchField)->lte($value); + + break; + } + } +} diff --git a/src/Doctrine/Odm/Filter/RangeFilter.php b/src/Doctrine/Odm/Filter/RangeFilter.php index df89d572170..383f2bed082 100644 --- a/src/Doctrine/Odm/Filter/RangeFilter.php +++ b/src/Doctrine/Odm/Filter/RangeFilter.php @@ -13,10 +13,7 @@ namespace ApiPlatform\Doctrine\Odm\Filter; -use ApiPlatform\Doctrine\Common\Filter\RangeFilterInterface; use ApiPlatform\Doctrine\Common\Filter\RangeFilterTrait; -use ApiPlatform\Metadata\Operation; -use Doctrine\ODM\MongoDB\Aggregation\Builder; /** * The range filter allows you to filter by a value lower than, greater than, lower than or equal, greater than or equal and between two values. @@ -101,105 +98,9 @@ * * @author Lee Siong Chan * @author Alan Poulain + * @author Kai Dederichs */ -final class RangeFilter extends AbstractFilter implements RangeFilterInterface +final class RangeFilter extends AbstractRangeFilter { use RangeFilterTrait; - - /** - * {@inheritdoc} - */ - protected function filterProperty(string $property, $values, Builder $aggregationBuilder, string $resourceClass, Operation $operation = null, array &$context = []): void - { - if ( - !\is_array($values) - || !$this->isPropertyEnabled($property, $resourceClass) - || !$this->isPropertyMapped($property, $resourceClass) - ) { - return; - } - - $values = $this->normalizeValues($values, $property); - if (null === $values) { - return; - } - - $matchField = $field = $property; - - if ($this->isPropertyNested($property, $resourceClass)) { - [$matchField] = $this->addLookupsForNestedProperty($property, $aggregationBuilder, $resourceClass); - } - - foreach ($values as $operator => $value) { - $this->addMatch( - $aggregationBuilder, - $field, - $matchField, - $operator, - $value - ); - } - } - - /** - * Adds the match stage according to the operator. - */ - protected function addMatch(Builder $aggregationBuilder, string $field, string $matchField, string $operator, string $value): void - { - switch ($operator) { - case self::PARAMETER_BETWEEN: - $rangeValue = explode('..', $value, 2); - - $rangeValue = $this->normalizeBetweenValues($rangeValue); - if (null === $rangeValue) { - return; - } - - if ($rangeValue[0] === $rangeValue[1]) { - $aggregationBuilder->match()->field($matchField)->equals($rangeValue[0]); - - return; - } - - $aggregationBuilder->match()->field($matchField)->gte($rangeValue[0])->lte($rangeValue[1]); - - break; - case self::PARAMETER_GREATER_THAN: - $value = $this->normalizeValue($value, $operator); - if (null === $value) { - return; - } - - $aggregationBuilder->match()->field($matchField)->gt($value); - - break; - case self::PARAMETER_GREATER_THAN_OR_EQUAL: - $value = $this->normalizeValue($value, $operator); - if (null === $value) { - return; - } - - $aggregationBuilder->match()->field($matchField)->gte($value); - - break; - case self::PARAMETER_LESS_THAN: - $value = $this->normalizeValue($value, $operator); - if (null === $value) { - return; - } - - $aggregationBuilder->match()->field($matchField)->lt($value); - - break; - case self::PARAMETER_LESS_THAN_OR_EQUAL: - $value = $this->normalizeValue($value, $operator); - if (null === $value) { - return; - } - - $aggregationBuilder->match()->field($matchField)->lte($value); - - break; - } - } } diff --git a/src/Doctrine/Odm/Filter/UuidRangeFilter.php b/src/Doctrine/Odm/Filter/UuidRangeFilter.php new file mode 100644 index 00000000000..2f5576767c1 --- /dev/null +++ b/src/Doctrine/Odm/Filter/UuidRangeFilter.php @@ -0,0 +1,28 @@ + + * + * 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\Doctrine\Odm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\UuidRangeFilterTrait; + +/** + * Filters the collection by range using UUIDs (UUID v6). + * + * @experimental + * + * @author Kai Dederichs + */ +final class UuidRangeFilter extends AbstractRangeFilter +{ + use UuidRangeFilterTrait; +} diff --git a/src/Doctrine/Orm/Filter/AbstractRangeFilter.php b/src/Doctrine/Orm/Filter/AbstractRangeFilter.php new file mode 100644 index 00000000000..905e81491d5 --- /dev/null +++ b/src/Doctrine/Orm/Filter/AbstractRangeFilter.php @@ -0,0 +1,148 @@ + + * + * 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\Doctrine\Orm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\RangeFilterInterface; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; + +/** + * Base for different filter implementations. + * + * @author Lee Siong Chan + * @author Kai Dederichs + */ +abstract class AbstractRangeFilter extends AbstractFilter implements RangeFilterInterface +{ + abstract protected function normalizeValue(string $value, string $operator); + + abstract protected function normalizeBetweenValues(array $values): ?array; + + abstract protected function normalizeValues(array $values, string $property): ?array; + + /** + * {@inheritdoc} + */ + protected function filterProperty(string $property, $values, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void + { + if ( + !\is_array($values) + || !$this->isPropertyEnabled($property, $resourceClass) + || !$this->isPropertyMapped($property, $resourceClass) + ) { + return; + } + + $values = $this->normalizeValues($values, $property); + if (null === $values) { + return; + } + + $alias = $queryBuilder->getRootAliases()[0]; + $field = $property; + + if ($this->isPropertyNested($property, $resourceClass)) { + [$alias, $field] = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $resourceClass, Join::INNER_JOIN); + } + + foreach ($values as $operator => $value) { + $this->addWhere( + $queryBuilder, + $queryNameGenerator, + $alias, + $field, + $operator, + $value + ); + } + } + + /** + * Adds the where clause according to the operator. + */ + protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, string $operator, string $value): void + { + $valueParameter = $queryNameGenerator->generateParameterName($field); + + switch ($operator) { + case self::PARAMETER_BETWEEN: + $rangeValue = explode('..', $value, 2); + + $rangeValue = $this->normalizeBetweenValues($rangeValue); + if (null === $rangeValue) { + return; + } + + if ($rangeValue[0] === $rangeValue[1]) { + $queryBuilder + ->andWhere(sprintf('%s.%s = :%s', $alias, $field, $valueParameter)) + ->setParameter($valueParameter, $rangeValue[0]); + + return; + } + + $queryBuilder + ->andWhere(sprintf('%1$s.%2$s BETWEEN :%3$s_1 AND :%3$s_2', $alias, $field, $valueParameter)) + ->setParameter(sprintf('%s_1', $valueParameter), $rangeValue[0]) + ->setParameter(sprintf('%s_2', $valueParameter), $rangeValue[1]); + + break; + case self::PARAMETER_GREATER_THAN: + $value = $this->normalizeValue($value, $operator); + if (null === $value) { + return; + } + + $queryBuilder + ->andWhere(sprintf('%s.%s > :%s', $alias, $field, $valueParameter)) + ->setParameter($valueParameter, $value); + + break; + case self::PARAMETER_GREATER_THAN_OR_EQUAL: + $value = $this->normalizeValue($value, $operator); + if (null === $value) { + return; + } + + $queryBuilder + ->andWhere(sprintf('%s.%s >= :%s', $alias, $field, $valueParameter)) + ->setParameter($valueParameter, $value); + + break; + case self::PARAMETER_LESS_THAN: + $value = $this->normalizeValue($value, $operator); + if (null === $value) { + return; + } + + $queryBuilder + ->andWhere(sprintf('%s.%s < :%s', $alias, $field, $valueParameter)) + ->setParameter($valueParameter, $value); + + break; + case self::PARAMETER_LESS_THAN_OR_EQUAL: + $value = $this->normalizeValue($value, $operator); + if (null === $value) { + return; + } + + $queryBuilder + ->andWhere(sprintf('%s.%s <= :%s', $alias, $field, $valueParameter)) + ->setParameter($valueParameter, $value); + + break; + } + } +} diff --git a/src/Doctrine/Orm/Filter/RangeFilter.php b/src/Doctrine/Orm/Filter/RangeFilter.php index 14233509b52..9f54aaec923 100644 --- a/src/Doctrine/Orm/Filter/RangeFilter.php +++ b/src/Doctrine/Orm/Filter/RangeFilter.php @@ -13,12 +13,7 @@ namespace ApiPlatform\Doctrine\Orm\Filter; -use ApiPlatform\Doctrine\Common\Filter\RangeFilterInterface; use ApiPlatform\Doctrine\Common\Filter\RangeFilterTrait; -use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; -use ApiPlatform\Metadata\Operation; -use Doctrine\ORM\Query\Expr\Join; -use Doctrine\ORM\QueryBuilder; /** * The range filter allows you to filter by a value lower than, greater than, lower than or equal, greater than or equal and between two values. @@ -103,121 +98,7 @@ * * @author Lee Siong Chan */ -final class RangeFilter extends AbstractFilter implements RangeFilterInterface +class RangeFilter extends AbstractRangeFilter { use RangeFilterTrait; - - /** - * {@inheritdoc} - */ - protected function filterProperty(string $property, $values, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void - { - if ( - !\is_array($values) - || !$this->isPropertyEnabled($property, $resourceClass) - || !$this->isPropertyMapped($property, $resourceClass) - ) { - return; - } - - $values = $this->normalizeValues($values, $property); - if (null === $values) { - return; - } - - $alias = $queryBuilder->getRootAliases()[0]; - $field = $property; - - if ($this->isPropertyNested($property, $resourceClass)) { - [$alias, $field] = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $resourceClass, Join::INNER_JOIN); - } - - foreach ($values as $operator => $value) { - $this->addWhere( - $queryBuilder, - $queryNameGenerator, - $alias, - $field, - $operator, - $value - ); - } - } - - /** - * Adds the where clause according to the operator. - */ - protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, string $operator, string $value): void - { - $valueParameter = $queryNameGenerator->generateParameterName($field); - - switch ($operator) { - case self::PARAMETER_BETWEEN: - $rangeValue = explode('..', $value, 2); - - $rangeValue = $this->normalizeBetweenValues($rangeValue); - if (null === $rangeValue) { - return; - } - - if ($rangeValue[0] === $rangeValue[1]) { - $queryBuilder - ->andWhere(sprintf('%s.%s = :%s', $alias, $field, $valueParameter)) - ->setParameter($valueParameter, $rangeValue[0]); - - return; - } - - $queryBuilder - ->andWhere(sprintf('%1$s.%2$s BETWEEN :%3$s_1 AND :%3$s_2', $alias, $field, $valueParameter)) - ->setParameter(sprintf('%s_1', $valueParameter), $rangeValue[0]) - ->setParameter(sprintf('%s_2', $valueParameter), $rangeValue[1]); - - break; - case self::PARAMETER_GREATER_THAN: - $value = $this->normalizeValue($value, $operator); - if (null === $value) { - return; - } - - $queryBuilder - ->andWhere(sprintf('%s.%s > :%s', $alias, $field, $valueParameter)) - ->setParameter($valueParameter, $value); - - break; - case self::PARAMETER_GREATER_THAN_OR_EQUAL: - $value = $this->normalizeValue($value, $operator); - if (null === $value) { - return; - } - - $queryBuilder - ->andWhere(sprintf('%s.%s >= :%s', $alias, $field, $valueParameter)) - ->setParameter($valueParameter, $value); - - break; - case self::PARAMETER_LESS_THAN: - $value = $this->normalizeValue($value, $operator); - if (null === $value) { - return; - } - - $queryBuilder - ->andWhere(sprintf('%s.%s < :%s', $alias, $field, $valueParameter)) - ->setParameter($valueParameter, $value); - - break; - case self::PARAMETER_LESS_THAN_OR_EQUAL: - $value = $this->normalizeValue($value, $operator); - if (null === $value) { - return; - } - - $queryBuilder - ->andWhere(sprintf('%s.%s <= :%s', $alias, $field, $valueParameter)) - ->setParameter($valueParameter, $value); - - break; - } - } } diff --git a/src/Doctrine/Orm/Filter/UuidRangeFilter.php b/src/Doctrine/Orm/Filter/UuidRangeFilter.php new file mode 100644 index 00000000000..36dd02024de --- /dev/null +++ b/src/Doctrine/Orm/Filter/UuidRangeFilter.php @@ -0,0 +1,26 @@ + + * + * 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\Doctrine\Orm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\UuidRangeFilterTrait; + +/** + * Filters the collection by range using UUIDs. + * + * @author Kai Dederichs + */ +final class UuidRangeFilter extends AbstractRangeFilter +{ + use UuidRangeFilterTrait; +} diff --git a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml index 7e49e0125a9..76d7d4ec42a 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml +++ b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml @@ -79,6 +79,13 @@ + + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml index 47f5f7a5ebc..c0e4626a44f 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml +++ b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml @@ -43,6 +43,14 @@ + + + null + + + + + diff --git a/tests/Behat/DoctrineContext.php b/tests/Behat/DoctrineContext.php index 89d4d9bfdd9..eaafe5ca0b1 100644 --- a/tests/Behat/DoctrineContext.php +++ b/tests/Behat/DoctrineContext.php @@ -184,6 +184,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SeparatedEntity; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Site; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SoMany; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SoManyUid; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SymfonyUuidDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Taxon; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; @@ -321,6 +322,35 @@ public function thereAreOfTheseSoManyObjects(int $nb): void $this->manager->flush(); } + /** + * @Given there are :nb of these so many uid objects + */ + public function thereAreOfTheseSoManyUidObjects(int $nb): void + { + $ids = [ + '018b7741-df00-7d0b-9895-bcc1fa36daad', // 1 + '018b7743-0bff-7298-9916-11e31114d2f4', + '018b7743-3122-7337-a658-f3fddb4b2764', + '018b7743-4e2f-7126-ac8c-befb62055095', + '018b7743-6a9f-72af-9587-c7e06f11c33e', + '018b7743-8758-7f01-a3fd-3e63af5eaa5f', + '018b7743-a62c-7be8-8e4c-67c363fd5a01', // 7 + '018b7743-c432-76ad-bb16-66151bb60a8a', + '018b7743-dfb1-708f-a76c-b645e49fc231', + '018b7743-f975-77da-8289-f25d475def71', // 10 + ]; + + for ($i = 1; $i <= $nb; ++$i) { + //$ids[] = UuidV7::v7()->toRfc4122(); + $id = $ids[$i - 1] ?? null; + $dummy = new SoManyUid($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/Doctrine/Common/Filter/UuidRangeFilterTestTrait.php b/tests/Doctrine/Common/Filter/UuidRangeFilterTestTrait.php new file mode 100644 index 00000000000..26a8e958f0c --- /dev/null +++ b/tests/Doctrine/Common/Filter/UuidRangeFilterTestTrait.php @@ -0,0 +1,136 @@ + + * + * 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\Doctrine\Common\Filter; + +trait UuidRangeFilterTestTrait +{ + private static function provideApplyTestArguments(): array + { + return [ + 'between' => [ + null, + [ + 'id' => [ + 'between' => '1ec5c128-f3d2-643a-8b17-68fef707f0bd..1ec5c128-f3d4-6514-8d2b-68fef707f0bd', + ], + ], + ], + 'between (same values)' => [ + null, + [ + 'id' => [ + 'between' => '1ec5c128-f3d2-643a-8b17-68fef707f0bd..1ec5c128-f3d2-643a-8b17-68fef707f0bd', + ], + ], + ], + 'between (too many operands)' => [ + null, + [ + 'id' => [ + 'between' => '1ec5c128-f3d2-643a-8b17-68fef707f0bd..1ec5c128-f3d4-6514-8d2b-68fef707f0bd..1ec5c128-f3d4-63f2-b845-68fef707f0bd', + ], + ], + ], + 'between (too few operands)' => [ + null, + [ + 'id' => [ + 'between' => '1ec5c128-f3d4-6514-8d2b-68fef707f0bd', + ], + ], + ], + 'between (non-uuid operands)' => [ + null, + [ + 'id' => [ + 'between' => 'abc..def', + ], + ], + ], + 'lt' => [ + null, + [ + 'id' => [ + 'lt' => '1ec5c128-f3d2-643a-8b17-68fef707f0bd', + ], + ], + ], + 'lt (non-uuid)' => [ + null, + [ + 'id' => [ + 'lt' => '127.0.0.1', + ], + ], + ], + 'lte' => [ + null, + [ + 'id' => [ + 'lte' => '1ec5c128-f3d2-643a-8b17-68fef707f0bd', + ], + ], + ], + 'lte (non-uuid)' => [ + null, + [ + 'id' => [ + 'lte' => '127.0.0.1', + ], + ], + ], + 'gt' => [ + null, + [ + 'id' => [ + 'gt' => '1ec5c128-f3d2-643a-8b17-68fef707f0bd', + ], + ], + ], + 'gt (non-uuid)' => [ + null, + [ + 'id' => [ + 'gt' => '127.0.0.1', + ], + ], + ], + 'gte' => [ + null, + [ + 'id' => [ + 'gte' => '1ec5c128-f3d2-643a-8b17-68fef707f0bd', + ], + ], + ], + 'gte (non-uuid)' => [ + null, + [ + 'id' => [ + 'gte' => '127.0.0.1', + ], + ], + ], + 'lte + gte' => [ + null, + [ + 'id' => [ + 'gte' => '1ec5c128-f3d2-643a-8b17-68fef707f0bd', + 'lte' => '1ec5c128-f3d4-6514-8d2b-68fef707f0bd', + ], + ], + ], + ]; + } +} diff --git a/tests/Doctrine/Odm/Filter/UuidRangeFilterTest.php b/tests/Doctrine/Odm/Filter/UuidRangeFilterTest.php new file mode 100644 index 00000000000..f5837cc0077 --- /dev/null +++ b/tests/Doctrine/Odm/Filter/UuidRangeFilterTest.php @@ -0,0 +1,178 @@ + + * + * 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\Doctrine\Odm\Filter; + +use ApiPlatform\Doctrine\Odm\Filter\UuidRangeFilter; +use ApiPlatform\Test\DoctrineMongoDbOdmFilterTestCase; +use ApiPlatform\Tests\Doctrine\Common\Filter\UuidRangeFilterTestTrait; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyUuidV6; + +/** + * @group mongodb + * + * @author Kai Dederichs + */ +class UuidRangeFilterTest extends DoctrineMongoDbOdmFilterTestCase +{ + use UuidRangeFilterTestTrait; + + protected string $filterClass = UuidRangeFilter::class; + protected string $resourceClass = DummyUuidV6::class; + + public function testGetDescriptionDefaultFields(): void + { + $filter = $this->buildFilter(); + + $this->assertEquals([ + 'id[between]' => [ + 'property' => 'id', + 'type' => 'string', + 'required' => false, + ], + 'id[gt]' => [ + 'property' => 'id', + 'type' => 'string', + 'required' => false, + ], + 'id[gte]' => [ + 'property' => 'id', + 'type' => 'string', + 'required' => false, + ], + 'id[lt]' => [ + 'property' => 'id', + 'type' => 'string', + 'required' => false, + ], + 'id[lte]' => [ + 'property' => 'id', + 'type' => 'string', + 'required' => false, + ], + ], $filter->getDescription($this->resourceClass)); + } + + public static function provideApplyTestData(): array + { + return array_merge_recursive( + self::provideApplyTestArguments(), + [ + 'between' => [ + [ + [ + '$match' => [ + '_id' => [ + '$gte' => '1ec5c128-f3d2-643a-8b17-68fef707f0bd', + '$lte' => '1ec5c128-f3d4-6514-8d2b-68fef707f0bd', + ], + ], + ], + ], + ], + 'between (same values)' => [ + [ + [ + '$match' => [ + '_id' => '1ec5c128-f3d2-643a-8b17-68fef707f0bd', + ], + ], + ], + ], + 'between (too many operands)' => [ + [], + ], + 'between (too few operands)' => [ + [], + ], + 'between (non-uuid operands)' => [ + [], + ], + 'lt' => [ + [ + [ + '$match' => [ + '_id' => [ + '$lt' => '1ec5c128-f3d2-643a-8b17-68fef707f0bd', + ], + ], + ], + ], + ], + 'lt (non-uuid)' => [ + [], + ], + 'lte' => [ + [ + [ + '$match' => [ + '_id' => [ + '$lte' => '1ec5c128-f3d2-643a-8b17-68fef707f0bd', + ], + ], + ], + ], + ], + 'lte (non-uuid)' => [ + [], + ], + 'gt' => [ + [ + [ + '$match' => [ + '_id' => [ + '$gt' => '1ec5c128-f3d2-643a-8b17-68fef707f0bd', + ], + ], + ], + ], + ], + 'gt (non-uuid)' => [ + [], + ], + 'gte' => [ + [ + [ + '$match' => [ + '_id' => [ + '$gte' => '1ec5c128-f3d2-643a-8b17-68fef707f0bd', + ], + ], + ], + ], + ], + 'gte (non-uuid)' => [ + [], + ], + 'lte + gte' => [ + [ + [ + '$match' => [ + '_id' => [ + '$gte' => '1ec5c128-f3d2-643a-8b17-68fef707f0bd', + ], + ], + ], + [ + '$match' => [ + '_id' => [ + '$lte' => '1ec5c128-f3d4-6514-8d2b-68fef707f0bd', + ], + ], + ], + ], + ], + ] + ); + } +} diff --git a/tests/Doctrine/Orm/Filter/UuidRangeFilterTest.php b/tests/Doctrine/Orm/Filter/UuidRangeFilterTest.php new file mode 100644 index 00000000000..12b6e3f0a58 --- /dev/null +++ b/tests/Doctrine/Orm/Filter/UuidRangeFilterTest.php @@ -0,0 +1,112 @@ + + * + * 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\Doctrine\Orm\Filter; + +use ApiPlatform\Doctrine\Orm\Filter\UuidRangeFilter; +use ApiPlatform\Test\DoctrineOrmFilterTestCase; +use ApiPlatform\Tests\Doctrine\Common\Filter\UuidRangeFilterTestTrait; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyUuidV6; + +class UuidRangeFilterTest extends DoctrineOrmFilterTestCase +{ + use UuidRangeFilterTestTrait; + + protected string $filterClass = UuidRangeFilter::class; + protected string $resourceClass = DummyUuidV6::class; + + public function testGetDescriptionDefaultFields(): void + { + $filter = $this->buildFilter(); + + $this->assertEquals([ + 'id[between]' => [ + 'property' => 'id', + 'type' => 'string', + 'required' => false, + ], + 'id[gt]' => [ + 'property' => 'id', + 'type' => 'string', + 'required' => false, + ], + 'id[gte]' => [ + 'property' => 'id', + 'type' => 'string', + 'required' => false, + ], + 'id[lt]' => [ + 'property' => 'id', + 'type' => 'string', + 'required' => false, + ], + 'id[lte]' => [ + 'property' => 'id', + 'type' => 'string', + 'required' => false, + ], + ], $filter->getDescription($this->resourceClass)); + } + + public static function provideApplyTestData(): array + { + return array_merge_recursive( + self::provideApplyTestArguments(), + [ + 'between' => [ + sprintf('SELECT o FROM %s o WHERE o.id BETWEEN :id_p1_1 AND :id_p1_2', Dummy::class), + ], + 'between (same values)' => [ + sprintf('SELECT o FROM %s o WHERE o.id = :id_p1', Dummy::class), + ], + 'between (too many operands)' => [ + sprintf('SELECT o FROM %s o', Dummy::class), + ], + 'between (too few operands)' => [ + sprintf('SELECT o FROM %s o', Dummy::class), + ], + 'between (non-uuid operands)' => [ + sprintf('SELECT o FROM %s o', Dummy::class), + ], + 'lt' => [ + sprintf('SELECT o FROM %s o WHERE o.id < :id_p1', Dummy::class), + ], + 'lt (non-uuid)' => [ + sprintf('SELECT o FROM %s o', Dummy::class), + ], + 'lte' => [ + sprintf('SELECT o FROM %s o WHERE o.id <= :id_p1', Dummy::class), + ], + 'lte (non-uuid)' => [ + sprintf('SELECT o FROM %s o', Dummy::class), + ], + 'gt' => [ + sprintf('SELECT o FROM %s o WHERE o.id > :id_p1', Dummy::class), + ], + 'gt (non-uuid)' => [ + sprintf('SELECT o FROM %s o', Dummy::class), + ], + 'gte' => [ + sprintf('SELECT o FROM %s o WHERE o.id >= :id_p1', Dummy::class), + ], + 'gte (non-uuid)' => [ + sprintf('SELECT o FROM %s o', Dummy::class), + ], + 'lte + gte' => [ + sprintf('SELECT o FROM %s o WHERE o.id >= :id_p1 AND o.id <= :id_p2', Dummy::class), + ], + ] + ); + } +} diff --git a/tests/Fixtures/TestBundle/Document/DummyUuidV6.php b/tests/Fixtures/TestBundle/Document/DummyUuidV6.php new file mode 100644 index 00000000000..564069391b4 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/DummyUuidV6.php @@ -0,0 +1,38 @@ + + * + * 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\Document; + +use ApiPlatform\Metadata\ApiResource; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Symfony\Component\Uid\Uuid; +use Symfony\Component\Uid\UuidV6; + + +#[ApiResource(extraProperties: ['doctrine_mongodb' => ['execute_options' => ['allowDiskUse' => true]]], filters: ['my_dummy.mongodb.uuid_range'])] +#[ODM\Document] +class DummyUuidV6 +{ + #[ODM\Id(strategy: 'NONE', type: 'string', nullable: true)] + private ?Uuid $id = null; + + public function __construct() + { + $this->id = UuidV6::v6(); + } + + public function getId(): ?Uuid + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/DummyUuidV6.php b/tests/Fixtures/TestBundle/Entity/DummyUuidV6.php new file mode 100644 index 00000000000..6115a318579 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/DummyUuidV6.php @@ -0,0 +1,39 @@ + + * + * 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\Metadata\ApiResource; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Uid\Uuid; + +#[ApiResource(filters: ['my_dummy.uuid_range'])] +#[ORM\Entity] +class DummyUuidV6 +{ + + #[ORM\Column(type: 'symfony_uuid')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private Uuid $id; + + public function __construct() + { + $this->id = Uuid::v7(); + } + + public function getId(): Uuid + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/SoManyUid.php b/tests/Fixtures/TestBundle/Entity/SoManyUid.php new file mode 100644 index 00000000000..ce49967dc7c --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/SoManyUid.php @@ -0,0 +1,49 @@ + + * + * 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\OrderFilter; +use ApiPlatform\Doctrine\Orm\Filter\UuidRangeFilter; +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiResource; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Uid\Uuid; + +#[ORM\Entity] +#[ApiResource( + paginationViaCursor: [ + ['field' => 'id', 'direction' => 'DESC'] + ], + paginationPartial: true +)] +#[ApiFilter(UuidRangeFilter::class, properties: ['id'])] +#[ApiFilter(OrderFilter::class, properties: ["id" => "DESC"])] +class SoManyUid +{ + #[ORM\Id] + #[ORM\Column(type: 'symfony_uuid')] + public Uuid $id; + + #[ORM\Column(nullable: true)] + public $content; + + public function __construct($id) + { + if ($id) { + $this->id = Uuid::fromString($id); + } else { + $this->id = Uuid::v7(); + } + } +} diff --git a/tests/Fixtures/app/config/config_mongodb.yml b/tests/Fixtures/app/config/config_mongodb.yml index ed65db4eb9f..e638cd6bb64 100644 --- a/tests/Fixtures/app/config/config_mongodb.yml +++ b/tests/Fixtures/app/config/config_mongodb.yml @@ -72,6 +72,10 @@ services: parent: 'api_platform.doctrine_mongodb.odm.search_filter' arguments: [ { 'name': 'ipartial', 'description': 'ipartial' } ] tags: [ { name: 'api_platform.filter', id: 'related_to_dummy_friend.mongodb.name' } ] + app.my_dummy_resource.mongodb.uuid_range_filter: + parent: 'api_platform.doctrine_mongodb.odm.uuid_range_filter' + arguments: [ { 'id': ~ } ] + tags: [ { name: 'api_platform.filter', id: 'my_dummy.mongodb.uuid_range' } ] ApiPlatform\Tests\Fixtures\TestBundle\State\ProductProvider: class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\ProductProvider' diff --git a/tests/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 0c355e9edef..395664294c2 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -752,6 +752,7 @@ public function testDoctrineOrmConfiguration(): void 'api_platform.doctrine.orm.search_filter', 'api_platform.doctrine.orm.order_filter', 'api_platform.doctrine.orm.range_filter', + 'api_platform.doctrine.orm.uuid_range_filter', 'api_platform.doctrine.orm.query_extension.eager_loading', 'api_platform.doctrine.orm.query_extension.filter', 'api_platform.doctrine.orm.query_extension.filter_eager_loading',