diff --git a/src/Doctrine/Orm/Filter/AbstractUuidFilter.php b/src/Doctrine/Orm/Filter/AbstractUuidFilter.php index 5f221fdc6b..3b7b552496 100644 --- a/src/Doctrine/Orm/Filter/AbstractUuidFilter.php +++ b/src/Doctrine/Orm/Filter/AbstractUuidFilter.php @@ -78,9 +78,11 @@ private function filterProperty(string $property, mixed $value, QueryBuilder $qu $metadata = $this->getNestedMetadata($resourceClass, $associations); + $operator = $context['operator'] ?? '='; + if ($metadata->hasField($field)) { $value = $this->convertValuesToTheDatabaseRepresentation($queryBuilder, $this->getDoctrineFieldType($property, $resourceClass), $value); - $this->addWhere($queryBuilder, $queryNameGenerator, $alias, $field, $value); + $this->addWhere($queryBuilder, $queryNameGenerator, $alias, $field, $value, $operator, $context); return; } @@ -111,7 +113,7 @@ private function filterProperty(string $property, mixed $value, QueryBuilder $qu } $value = $this->convertValuesToTheDatabaseRepresentation($queryBuilder, $doctrineTypeField, $value); - $this->addWhere($queryBuilder, $queryNameGenerator, $associationAlias, $associationField, $value); + $this->addWhere($queryBuilder, $queryNameGenerator, $associationAlias, $associationField, $value, $operator, $context); } /** @@ -144,21 +146,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 194c985eb3..b45d8811e2 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/Metadata/Tests/Property/Factory/SerializerPropertyMetadataFactoryTest.php b/src/Metadata/Tests/Property/Factory/SerializerPropertyMetadataFactoryTest.php index d7b5e28799..0921b7a756 100644 --- a/src/Metadata/Tests/Property/Factory/SerializerPropertyMetadataFactoryTest.php +++ b/src/Metadata/Tests/Property/Factory/SerializerPropertyMetadataFactoryTest.php @@ -119,7 +119,7 @@ public static function attributesProvider(): array ]; } - #[\PHPUnit\Framework\Attributes\DataProvider('attributesProvider')] + #[DataProvider('attributesProvider')] public function testCreateWithAttributes($readAttributes, $writeAttributes): void { $serializerClassMetadataFactoryProphecy = $this->prophesize(SerializerClassMetadataFactoryInterface::class); 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/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..26caeea180 --- /dev/null +++ b/tests/Functional/Parameters/ComparisonFilterTest.php @@ -0,0 +1,144 @@ + + * + * 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\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 + { + $this->recreateSchema([Chicken::class, ChickenCoop::class, Owner::class]); + $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(); + + $owner = new Owner(); + $owner->setName('TestOwner'); + $manager->persist($owner); + + $coop = new ChickenCoop(); + $manager->persist($coop); + + foreach (['Alpha', 'Bravo', 'Charlie', 'Delta'] as $name) { + $chicken = new Chicken(); + $chicken->setName($name); + $chicken->setEan('000000000000'); + $chicken->setChickenCoop($coop); + $chicken->setOwner($owner); + $coop->addChicken($chicken); + $manager->persist($chicken); + } + + $manager->flush(); + } +}