diff --git a/src/Doctrine/Odm/Filter/ComparisonFilter.php b/src/Doctrine/Odm/Filter/ComparisonFilter.php new file mode 100644 index 0000000000..0437e904ae --- /dev/null +++ b/src/Doctrine/Odm/Filter/ComparisonFilter.php @@ -0,0 +1,129 @@ + + * + * 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\LoggerAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; +use Doctrine\ODM\MongoDB\Aggregation\Builder; + +/** + * Decorates an equality filter (ExactFilter) to add comparison operators (gt, gte, lt, lte, between). + * + * @experimental + */ +final class ComparisonFilter implements FilterInterface, OpenApiParameterFilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use LoggerAwareTrait; + use ManagerRegistryAwareTrait; + use OpenApiFilterTrait; + + private const OPERATORS = [ + 'gt' => 'gt', + 'gte' => 'gte', + 'lt' => 'lt', + 'lte' => 'lte', + ]; + + public function __construct(private readonly FilterInterface $filter) + { + } + + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + if ($this->filter instanceof ManagerRegistryAwareInterface) { + $this->filter->setManagerRegistry($this->getManagerRegistry()); + } + + if ($this->filter instanceof LoggerAwareInterface) { + $this->filter->setLogger($this->getLogger()); + } + + $parameter = $context['parameter']; + $values = $parameter->getValue(); + + if (!\is_array($values)) { + return; + } + + foreach ($values as $operator => $value) { + if ('' === $value || null === $value) { + continue; + } + + if (isset(self::OPERATORS[$operator])) { + $subParameter = (clone $parameter)->setValue($value); + $newContext = ['comparison' => self::OPERATORS[$operator], 'parameter' => $subParameter] + $context; + $this->filter->apply($aggregationBuilder, $resourceClass, $operation, $newContext); + if (isset($newContext['match'])) { + $context['match'] = $newContext['match']; + } + continue; + } + + if ('between' === $operator) { + $range = explode('..', (string) $value, 2); + if (2 !== \count($range)) { + continue; + } + + if ($range[0] === $range[1]) { + $subParameter = (clone $parameter)->setValue($range[0]); + $newContext = ['parameter' => $subParameter] + $context; + $this->filter->apply($aggregationBuilder, $resourceClass, $operation, $newContext); + if (isset($newContext['match'])) { + $context['match'] = $newContext['match']; + } + } else { + $subParameter = (clone $parameter)->setValue($range[0]); + $newContext = ['comparison' => 'gte', 'parameter' => $subParameter] + $context; + $this->filter->apply($aggregationBuilder, $resourceClass, $operation, $newContext); + if (isset($newContext['match'])) { + $context['match'] = $newContext['match']; + } + + $subParameter = (clone $parameter)->setValue($range[1]); + $newContext = ['comparison' => 'lte', 'parameter' => $subParameter] + $context; + $this->filter->apply($aggregationBuilder, $resourceClass, $operation, $newContext); + if (isset($newContext['match'])) { + $context['match'] = $newContext['match']; + } + } + } + } + } + + public function getOpenApiParameters(Parameter $parameter): array + { + $in = $parameter instanceof QueryParameter ? 'query' : 'header'; + $key = $parameter->getKey(); + + return [ + new OpenApiParameter(name: "{$key}[gt]", in: $in), + new OpenApiParameter(name: "{$key}[gte]", in: $in), + new OpenApiParameter(name: "{$key}[lt]", in: $in), + new OpenApiParameter(name: "{$key}[lte]", in: $in), + new OpenApiParameter(name: "{$key}[between]", in: $in), + ]; + } +} diff --git a/src/Doctrine/Odm/Filter/ExactFilter.php b/src/Doctrine/Odm/Filter/ExactFilter.php index 17c664393f..a52bd269ff 100644 --- a/src/Doctrine/Odm/Filter/ExactFilter.php +++ b/src/Doctrine/Odm/Filter/ExactFilter.php @@ -61,8 +61,9 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera $classMetadata = $documentManager->getClassMetadata($resourceClass); if (!$classMetadata->hasReference($property)) { + $comparison = $context['comparison'] ?? (is_iterable($value) ? 'in' : 'equals'); $match - ->{$operator}($aggregationBuilder->matchExpr()->field($property)->{is_iterable($value) ? 'in' : 'equals'}($value)); + ->{$operator}($aggregationBuilder->matchExpr()->field($property)->{$comparison}($value)); return; } diff --git a/src/Doctrine/Orm/Filter/AbstractUuidFilter.php b/src/Doctrine/Orm/Filter/AbstractUuidFilter.php index 84b73db228..7998990a92 100644 --- a/src/Doctrine/Orm/Filter/AbstractUuidFilter.php +++ b/src/Doctrine/Orm/Filter/AbstractUuidFilter.php @@ -96,9 +96,11 @@ private function filterProperty(Parameter $parameter, QueryBuilder $queryBuilder $metadata = $this->getClassMetadata($targetResourceClass); + $operator = $context['operator'] ?? '='; + if ($metadata->hasField($field)) { $value = $this->convertValuesToTheDatabaseRepresentation($queryBuilder, $this->getDoctrineFieldType($field, $targetResourceClass), $value); - $this->addWhere($queryBuilder, $queryNameGenerator, $alias, $field, $value); + $this->addWhere($queryBuilder, $queryNameGenerator, $alias, $field, $value, $operator, $context); return; } @@ -129,7 +131,7 @@ private function filterProperty(Parameter $parameter, QueryBuilder $queryBuilder } $value = $this->convertValuesToTheDatabaseRepresentation($queryBuilder, $doctrineTypeField, $value); - $this->addWhere($queryBuilder, $queryNameGenerator, $associationAlias, $associationField, $value); + $this->addWhere($queryBuilder, $queryNameGenerator, $associationAlias, $associationField, $value, $operator, $context); } /** @@ -162,21 +164,28 @@ private function convertValuesToTheDatabaseRepresentation(QueryBuilder $queryBui /** * Adds where clause. */ - private function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, mixed $value): void + private function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, mixed $value, string $operator = '=', array $context = []): void { $valueParameter = ':'.$queryNameGenerator->generateParameterName($field); $aliasedField = \sprintf('%s.%s', $alias, $field); + $whereClause = $context['whereClause'] ?? 'andWhere'; if (!\is_array($value)) { - $queryBuilder - ->andWhere($queryBuilder->expr()->eq($aliasedField, $valueParameter)) - ->setParameter($valueParameter, $value, $this->getDoctrineParameterType()); + if ('=' === $operator) { + $queryBuilder + ->{$whereClause}($queryBuilder->expr()->eq($aliasedField, $valueParameter)) + ->setParameter($valueParameter, $value, $this->getDoctrineParameterType()); + } else { + $queryBuilder + ->{$whereClause}(\sprintf('%s %s %s', $aliasedField, $operator, $valueParameter)) + ->setParameter($valueParameter, $value, $this->getDoctrineParameterType()); + } return; } $queryBuilder - ->andWhere($queryBuilder->expr()->in($aliasedField, $valueParameter)) + ->{$whereClause}($queryBuilder->expr()->in($aliasedField, $valueParameter)) ->setParameter($valueParameter, $value, $this->getDoctrineArrayParameterType()); } diff --git a/src/Doctrine/Orm/Filter/ComparisonFilter.php b/src/Doctrine/Orm/Filter/ComparisonFilter.php new file mode 100644 index 0000000000..9b9943c426 --- /dev/null +++ b/src/Doctrine/Orm/Filter/ComparisonFilter.php @@ -0,0 +1,138 @@ + + * + * 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\LoggerAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; +use Doctrine\ORM\QueryBuilder; + +/** + * Decorates an equality filter (ExactFilter, UuidFilter) to add comparison operators (gt, gte, lt, lte, between). + * + * @experimental + */ +final class ComparisonFilter implements FilterInterface, OpenApiParameterFilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use LoggerAwareTrait; + use ManagerRegistryAwareTrait; + use OpenApiFilterTrait; + + private const OPERATORS = [ + 'gt' => '>', + 'gte' => '>=', + 'lt' => '<', + 'lte' => '<=', + ]; + + public function __construct(private readonly FilterInterface $filter) + { + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + if ($this->filter instanceof ManagerRegistryAwareInterface) { + $this->filter->setManagerRegistry($this->getManagerRegistry()); + } + + if ($this->filter instanceof LoggerAwareInterface) { + $this->filter->setLogger($this->getLogger()); + } + + $parameter = $context['parameter']; + $values = $parameter->getValue(); + + if (!\is_array($values)) { + return; + } + + foreach ($values as $operator => $value) { + if ('' === $value || null === $value) { + continue; + } + + if (isset(self::OPERATORS[$operator])) { + $subParameter = (clone $parameter)->setValue($value); + $this->filter->apply( + $queryBuilder, + $queryNameGenerator, + $resourceClass, + $operation, + ['operator' => self::OPERATORS[$operator], 'parameter' => $subParameter] + $context + ); + continue; + } + + if ('between' === $operator) { + $range = explode('..', (string) $value, 2); + if (2 !== \count($range)) { + continue; + } + + if ($range[0] === $range[1]) { + $subParameter = (clone $parameter)->setValue($range[0]); + $this->filter->apply( + $queryBuilder, + $queryNameGenerator, + $resourceClass, + $operation, + ['parameter' => $subParameter] + $context + ); + } else { + $subParameter = (clone $parameter)->setValue($range[0]); + $this->filter->apply( + $queryBuilder, + $queryNameGenerator, + $resourceClass, + $operation, + ['operator' => '>=', 'parameter' => $subParameter] + $context + ); + + $subParameter = (clone $parameter)->setValue($range[1]); + $this->filter->apply( + $queryBuilder, + $queryNameGenerator, + $resourceClass, + $operation, + ['operator' => '<=', 'parameter' => $subParameter] + $context + ); + } + } + } + } + + public function getOpenApiParameters(Parameter $parameter): array + { + $in = $parameter instanceof QueryParameter ? 'query' : 'header'; + $key = $parameter->getKey(); + + return [ + new OpenApiParameter(name: "{$key}[gt]", in: $in), + new OpenApiParameter(name: "{$key}[gte]", in: $in), + new OpenApiParameter(name: "{$key}[lt]", in: $in), + new OpenApiParameter(name: "{$key}[lte]", in: $in), + new OpenApiParameter(name: "{$key}[between]", in: $in), + ]; + } +} diff --git a/src/Doctrine/Orm/Filter/ExactFilter.php b/src/Doctrine/Orm/Filter/ExactFilter.php index 1420fcbb40..5ca7ecdf36 100644 --- a/src/Doctrine/Orm/Filter/ExactFilter.php +++ b/src/Doctrine/Orm/Filter/ExactFilter.php @@ -50,8 +50,9 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q $queryBuilder ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s IN (:%s)', $alias, $property, $parameterName)); } else { + $operator = $context['operator'] ?? '='; $queryBuilder - ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s = :%s', $alias, $property, $parameterName)); + ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s %s :%s', $alias, $property, $operator, $parameterName)); } $queryBuilder->setParameter($parameterName, $value); diff --git a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php index 17d22bbf58..094a6fa459 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php +++ b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php @@ -20,6 +20,7 @@ use ApiPlatform\Doctrine\Odm\Extension\PaginationExtension; use ApiPlatform\Doctrine\Odm\Extension\ParameterExtension; use ApiPlatform\Doctrine\Odm\Filter\BooleanFilter; +use ApiPlatform\Doctrine\Odm\Filter\ComparisonFilter; use ApiPlatform\Doctrine\Odm\Filter\DateFilter; use ApiPlatform\Doctrine\Odm\Filter\ExistsFilter; use ApiPlatform\Doctrine\Odm\Filter\NumericFilter; @@ -151,6 +152,9 @@ ->parent('api_platform.doctrine_mongodb.odm.range_filter') ->args([[]]); + $services->set('api_platform.doctrine_mongodb.odm.comparison_filter', ComparisonFilter::class); + $services->alias(ComparisonFilter::class, 'api_platform.doctrine_mongodb.odm.comparison_filter'); + $services->set('api_platform.doctrine_mongodb.odm.aggregation_extension.filter', FilterExtension::class) ->args([service('api_platform.filter_locator')]) ->tag('api_platform.doctrine_mongodb.odm.aggregation_extension.collection', ['priority' => 32]); diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm.php b/src/Symfony/Bundle/Resources/config/doctrine_orm.php index 4a2c6772e3..55ad06adb6 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_orm.php +++ b/src/Symfony/Bundle/Resources/config/doctrine_orm.php @@ -23,6 +23,7 @@ use ApiPlatform\Doctrine\Orm\Extension\ParameterExtension; use ApiPlatform\Doctrine\Orm\Filter\BackedEnumFilter; use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; +use ApiPlatform\Doctrine\Orm\Filter\ComparisonFilter; use ApiPlatform\Doctrine\Orm\Filter\DateFilter; use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter; use ApiPlatform\Doctrine\Orm\Filter\NumericFilter; @@ -252,6 +253,9 @@ ->parent('api_platform.doctrine.orm.search_filter') ->args([[]]); + $services->set('api_platform.doctrine.orm.comparison_filter', ComparisonFilter::class); + $services->alias(ComparisonFilter::class, 'api_platform.doctrine.orm.comparison_filter'); + $services->set('api_platform.doctrine.orm.uuid_filter', UuidFilter::class); $services->alias(UuidFilter::class, 'api_platform.doctrine.orm.uuid_filter'); diff --git a/tests/Fixtures/TestBundle/Document/Chicken.php b/tests/Fixtures/TestBundle/Document/Chicken.php index cf530ad586..11df7b6429 100644 --- a/tests/Fixtures/TestBundle/Document/Chicken.php +++ b/tests/Fixtures/TestBundle/Document/Chicken.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; +use ApiPlatform\Doctrine\Odm\Filter\ComparisonFilter; use ApiPlatform\Doctrine\Odm\Filter\ExactFilter; use ApiPlatform\Doctrine\Odm\Filter\FreeTextQueryFilter; use ApiPlatform\Doctrine\Odm\Filter\IriFilter; @@ -62,6 +63,10 @@ filter: new ExactFilter(), properties: ['owner.name'], ), + 'idComparison' => new QueryParameter( + filter: new ComparisonFilter(new ExactFilter()), + property: 'id', + ), ], ), new Get(), diff --git a/tests/Fixtures/TestBundle/Entity/Chicken.php b/tests/Fixtures/TestBundle/Entity/Chicken.php index 8fd7fe1a8e..83101bc3f6 100644 --- a/tests/Fixtures/TestBundle/Entity/Chicken.php +++ b/tests/Fixtures/TestBundle/Entity/Chicken.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; +use ApiPlatform\Doctrine\Orm\Filter\ComparisonFilter; use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; use ApiPlatform\Doctrine\Orm\Filter\FreeTextQueryFilter; use ApiPlatform\Doctrine\Orm\Filter\IriFilter; @@ -62,6 +63,10 @@ filter: new ExactFilter(), properties: ['owner.name'], ), + 'idComparison' => new QueryParameter( + filter: new ComparisonFilter(new ExactFilter()), + property: 'id', + ), ], ), new Get(), diff --git a/tests/Functional/Parameters/ComparisonFilterTest.php b/tests/Functional/Parameters/ComparisonFilterTest.php new file mode 100644 index 0000000000..f689e9440c --- /dev/null +++ b/tests/Functional/Parameters/ComparisonFilterTest.php @@ -0,0 +1,155 @@ + + * + * 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\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Chicken as DocumentChicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ChickenCoop as DocumentChickenCoop; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Owner as DocumentOwner; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Chicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ChickenCoop; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Owner; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; + +final class ComparisonFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Chicken::class, ChickenCoop::class, Owner::class]; + } + + protected function setUp(): void + { + $entities = $this->isMongoDB() + ? [DocumentChicken::class, DocumentChickenCoop::class, DocumentOwner::class] + : [Chicken::class, ChickenCoop::class, Owner::class]; + + $this->recreateSchema($entities); + $this->loadFixtures(); + } + + #[DataProvider('comparisonFilterProvider')] + public function testComparisonFilter(string $url, int $expectedCount, array $expectedNames): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['member']; + + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + + $names = array_map(static fn ($chicken) => $chicken['name'], $filteredItems); + sort($names); + sort($expectedNames); + + $this->assertSame($expectedNames, $names, 'The names do not match the expected values.'); + } + + public static function comparisonFilterProvider(): \Generator + { + // We create 4 chickens with IDs 1-4: Alpha, Bravo, Charlie, Delta + yield 'gt: id > 2 returns chickens 3,4' => [ + '/chickens?idComparison[gt]=2', + 2, + ['Charlie', 'Delta'], + ]; + + yield 'gte: id >= 2 returns chickens 2,3,4' => [ + '/chickens?idComparison[gte]=2', + 3, + ['Bravo', 'Charlie', 'Delta'], + ]; + + yield 'lt: id < 3 returns chickens 1,2' => [ + '/chickens?idComparison[lt]=3', + 2, + ['Alpha', 'Bravo'], + ]; + + yield 'lte: id <= 3 returns chickens 1,2,3' => [ + '/chickens?idComparison[lte]=3', + 3, + ['Alpha', 'Bravo', 'Charlie'], + ]; + + yield 'between: id between 2..3 returns chickens 2,3' => [ + '/chickens?idComparison[between]=2..3', + 2, + ['Bravo', 'Charlie'], + ]; + + yield 'between equal values: id between 2..2 returns chicken 2 (equality)' => [ + '/chickens?idComparison[between]=2..2', + 1, + ['Bravo'], + ]; + + yield 'combined gt and lt: id > 1 AND id < 4 returns chickens 2,3' => [ + '/chickens?idComparison[gt]=1&idComparison[lt]=4', + 2, + ['Bravo', 'Charlie'], + ]; + + yield 'gt with no results: id > 100 returns nothing' => [ + '/chickens?idComparison[gt]=100', + 0, + [], + ]; + + yield 'gte with large range returns all' => [ + '/chickens?idComparison[gte]=1&itemsPerPage=10', + 4, + ['Alpha', 'Bravo', 'Charlie', 'Delta'], + ]; + } + + private function loadFixtures(): void + { + $manager = $this->getManager(); + + $chickenClass = $this->isMongoDB() ? DocumentChicken::class : Chicken::class; + $coopClass = $this->isMongoDB() ? DocumentChickenCoop::class : ChickenCoop::class; + $ownerClass = $this->isMongoDB() ? DocumentOwner::class : Owner::class; + + $owner = new $ownerClass(); + $owner->setName('TestOwner'); + $manager->persist($owner); + + $coop = new $coopClass(); + $manager->persist($coop); + + foreach (['Alpha', 'Bravo', 'Charlie', 'Delta'] as $name) { + $chicken = new $chickenClass(); + $chicken->setName($name); + $chicken->setEan('000000000000'); + $chicken->setChickenCoop($coop); + $chicken->setOwner($owner); + $coop->addChicken($chicken); + $manager->persist($chicken); + } + + $manager->flush(); + } +}