Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(pagination): Add UuidRangeFilter to allow Uuid cursors #4689

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
91 changes: 91 additions & 0 deletions features/hydra/collection.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions src/Doctrine/Common/Filter/RangeFilterTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <[email protected]>
* @author Alan Poulain <[email protected]>
Expand Down Expand Up @@ -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];

Expand All @@ -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', [
Expand All @@ -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', [
Expand Down
67 changes: 67 additions & 0 deletions src/Doctrine/Common/Filter/UuidRangeFilterTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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 <[email protected]>
*/
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 "<min>..<max>"', 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);
}
}
134 changes: 134 additions & 0 deletions src/Doctrine/Odm/Filter/AbstractRangeFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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 <[email protected]>
* @author Alan Poulain <[email protected]>
* @author Kai Dederichs <[email protected]>
*/
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;
}
}
}
Loading
Loading