Skip to content

Commit 26970ec

Browse files
committed
feat(doctrine): ComparisonFilter decorator for range filtering
| Q | A | ------------- | --- | Branch? | main | Tickets | ∅ | License | MIT | Doc PR | ∅ Decorator-based ComparisonFilter that composes with equality filters (ExactFilter, UuidFilter) to add gt, gte, lt, lte, between operators. Follows the same pattern as OrFilter by injecting $context['operator'].
1 parent 771ad58 commit 26970ec

7 files changed

Lines changed: 310 additions & 9 deletions

File tree

src/Doctrine/Orm/Filter/AbstractUuidFilter.php

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,11 @@ private function filterProperty(string $property, mixed $value, QueryBuilder $qu
7878

7979
$metadata = $this->getNestedMetadata($resourceClass, $associations);
8080

81+
$operator = $context['operator'] ?? '=';
82+
8183
if ($metadata->hasField($field)) {
8284
$value = $this->convertValuesToTheDatabaseRepresentation($queryBuilder, $this->getDoctrineFieldType($property, $resourceClass), $value);
83-
$this->addWhere($queryBuilder, $queryNameGenerator, $alias, $field, $value);
85+
$this->addWhere($queryBuilder, $queryNameGenerator, $alias, $field, $value, $operator, $context);
8486

8587
return;
8688
}
@@ -111,7 +113,7 @@ private function filterProperty(string $property, mixed $value, QueryBuilder $qu
111113
}
112114

113115
$value = $this->convertValuesToTheDatabaseRepresentation($queryBuilder, $doctrineTypeField, $value);
114-
$this->addWhere($queryBuilder, $queryNameGenerator, $associationAlias, $associationField, $value);
116+
$this->addWhere($queryBuilder, $queryNameGenerator, $associationAlias, $associationField, $value, $operator, $context);
115117
}
116118

117119
/**
@@ -144,21 +146,28 @@ private function convertValuesToTheDatabaseRepresentation(QueryBuilder $queryBui
144146
/**
145147
* Adds where clause.
146148
*/
147-
private function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, mixed $value): void
149+
private function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, mixed $value, string $operator = '=', array $context = []): void
148150
{
149151
$valueParameter = ':'.$queryNameGenerator->generateParameterName($field);
150152
$aliasedField = \sprintf('%s.%s', $alias, $field);
153+
$whereClause = $context['whereClause'] ?? 'andWhere';
151154

152155
if (!\is_array($value)) {
153-
$queryBuilder
154-
->andWhere($queryBuilder->expr()->eq($aliasedField, $valueParameter))
155-
->setParameter($valueParameter, $value, $this->getDoctrineParameterType());
156+
if ('=' === $operator) {
157+
$queryBuilder
158+
->{$whereClause}($queryBuilder->expr()->eq($aliasedField, $valueParameter))
159+
->setParameter($valueParameter, $value, $this->getDoctrineParameterType());
160+
} else {
161+
$queryBuilder
162+
->{$whereClause}(\sprintf('%s %s %s', $aliasedField, $operator, $valueParameter))
163+
->setParameter($valueParameter, $value, $this->getDoctrineParameterType());
164+
}
156165

157166
return;
158167
}
159168

160169
$queryBuilder
161-
->andWhere($queryBuilder->expr()->in($aliasedField, $valueParameter))
170+
->{$whereClause}($queryBuilder->expr()->in($aliasedField, $valueParameter))
162171
->setParameter($valueParameter, $value, $this->getDoctrineArrayParameterType());
163172
}
164173

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Doctrine\Orm\Filter;
15+
16+
use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface;
17+
use ApiPlatform\Doctrine\Common\Filter\LoggerAwareTrait;
18+
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
19+
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait;
20+
use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait;
21+
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
22+
use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait;
23+
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
24+
use ApiPlatform\Metadata\Operation;
25+
use ApiPlatform\Metadata\Parameter;
26+
use ApiPlatform\Metadata\QueryParameter;
27+
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
28+
use Doctrine\ORM\QueryBuilder;
29+
30+
/**
31+
* Decorates an equality filter (ExactFilter, UuidFilter) to add comparison operators (gt, gte, lt, lte, between).
32+
*
33+
* @experimental
34+
*/
35+
final class ComparisonFilter implements FilterInterface, OpenApiParameterFilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface
36+
{
37+
use BackwardCompatibleFilterDescriptionTrait;
38+
use LoggerAwareTrait;
39+
use ManagerRegistryAwareTrait;
40+
use OpenApiFilterTrait;
41+
42+
private const OPERATORS = [
43+
'gt' => '>',
44+
'gte' => '>=',
45+
'lt' => '<',
46+
'lte' => '<=',
47+
];
48+
49+
public function __construct(private readonly FilterInterface $filter)
50+
{
51+
}
52+
53+
public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
54+
{
55+
if ($this->filter instanceof ManagerRegistryAwareInterface) {
56+
$this->filter->setManagerRegistry($this->getManagerRegistry());
57+
}
58+
59+
if ($this->filter instanceof LoggerAwareInterface) {
60+
$this->filter->setLogger($this->getLogger());
61+
}
62+
63+
$parameter = $context['parameter'];
64+
$values = $parameter->getValue();
65+
66+
if (!\is_array($values)) {
67+
return;
68+
}
69+
70+
foreach ($values as $operator => $value) {
71+
if ('' === $value || null === $value) {
72+
continue;
73+
}
74+
75+
if (isset(self::OPERATORS[$operator])) {
76+
$subParameter = (clone $parameter)->setValue($value);
77+
$this->filter->apply(
78+
$queryBuilder,
79+
$queryNameGenerator,
80+
$resourceClass,
81+
$operation,
82+
['operator' => self::OPERATORS[$operator], 'parameter' => $subParameter] + $context
83+
);
84+
continue;
85+
}
86+
87+
if ('between' === $operator) {
88+
$range = explode('..', (string) $value, 2);
89+
if (2 !== \count($range)) {
90+
continue;
91+
}
92+
93+
if ($range[0] === $range[1]) {
94+
$subParameter = (clone $parameter)->setValue($range[0]);
95+
$this->filter->apply(
96+
$queryBuilder,
97+
$queryNameGenerator,
98+
$resourceClass,
99+
$operation,
100+
['parameter' => $subParameter] + $context
101+
);
102+
} else {
103+
$subParameter = (clone $parameter)->setValue($range[0]);
104+
$this->filter->apply(
105+
$queryBuilder,
106+
$queryNameGenerator,
107+
$resourceClass,
108+
$operation,
109+
['operator' => '>=', 'parameter' => $subParameter] + $context
110+
);
111+
112+
$subParameter = (clone $parameter)->setValue($range[1]);
113+
$this->filter->apply(
114+
$queryBuilder,
115+
$queryNameGenerator,
116+
$resourceClass,
117+
$operation,
118+
['operator' => '<=', 'parameter' => $subParameter] + $context
119+
);
120+
}
121+
}
122+
}
123+
}
124+
125+
public function getOpenApiParameters(Parameter $parameter): array
126+
{
127+
$in = $parameter instanceof QueryParameter ? 'query' : 'header';
128+
$key = $parameter->getKey();
129+
130+
return [
131+
new OpenApiParameter(name: "{$key}[gt]", in: $in),
132+
new OpenApiParameter(name: "{$key}[gte]", in: $in),
133+
new OpenApiParameter(name: "{$key}[lt]", in: $in),
134+
new OpenApiParameter(name: "{$key}[lte]", in: $in),
135+
new OpenApiParameter(name: "{$key}[between]", in: $in),
136+
];
137+
}
138+
}

src/Doctrine/Orm/Filter/ExactFilter.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,9 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q
5050
$queryBuilder
5151
->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s IN (:%s)', $alias, $property, $parameterName));
5252
} else {
53+
$operator = $context['operator'] ?? '=';
5354
$queryBuilder
54-
->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s = :%s', $alias, $property, $parameterName));
55+
->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s %s :%s', $alias, $property, $operator, $parameterName));
5556
}
5657

5758
$queryBuilder->setParameter($parameterName, $value);

src/Metadata/Tests/Property/Factory/SerializerPropertyMetadataFactoryTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ public static function attributesProvider(): array
119119
];
120120
}
121121

122-
#[\PHPUnit\Framework\Attributes\DataProvider('attributesProvider')]
122+
#[DataProvider('attributesProvider')]
123123
public function testCreateWithAttributes($readAttributes, $writeAttributes): void
124124
{
125125
$serializerClassMetadataFactoryProphecy = $this->prophesize(SerializerClassMetadataFactoryInterface::class);

src/Symfony/Bundle/Resources/config/doctrine_orm.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use ApiPlatform\Doctrine\Orm\Extension\ParameterExtension;
2424
use ApiPlatform\Doctrine\Orm\Filter\BackedEnumFilter;
2525
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
26+
use ApiPlatform\Doctrine\Orm\Filter\ComparisonFilter;
2627
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
2728
use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter;
2829
use ApiPlatform\Doctrine\Orm\Filter\NumericFilter;
@@ -252,6 +253,9 @@
252253
->parent('api_platform.doctrine.orm.search_filter')
253254
->args([[]]);
254255

256+
$services->set('api_platform.doctrine.orm.comparison_filter', ComparisonFilter::class);
257+
$services->alias(ComparisonFilter::class, 'api_platform.doctrine.orm.comparison_filter');
258+
255259
$services->set('api_platform.doctrine.orm.uuid_filter', UuidFilter::class);
256260
$services->alias(UuidFilter::class, 'api_platform.doctrine.orm.uuid_filter');
257261

tests/Fixtures/TestBundle/Entity/Chicken.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity;
1515

16+
use ApiPlatform\Doctrine\Orm\Filter\ComparisonFilter;
1617
use ApiPlatform\Doctrine\Orm\Filter\ExactFilter;
1718
use ApiPlatform\Doctrine\Orm\Filter\FreeTextQueryFilter;
1819
use ApiPlatform\Doctrine\Orm\Filter\IriFilter;
@@ -62,6 +63,10 @@
6263
filter: new ExactFilter(),
6364
properties: ['owner.name'],
6465
),
66+
'idComparison' => new QueryParameter(
67+
filter: new ComparisonFilter(new ExactFilter()),
68+
property: 'id',
69+
),
6570
],
6671
),
6772
new Get(),
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Functional\Parameters;
15+
16+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
17+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Chicken;
18+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ChickenCoop;
19+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Owner;
20+
use ApiPlatform\Tests\RecreateSchemaTrait;
21+
use ApiPlatform\Tests\SetupClassResourcesTrait;
22+
use PHPUnit\Framework\Attributes\DataProvider;
23+
24+
final class ComparisonFilterTest extends ApiTestCase
25+
{
26+
use RecreateSchemaTrait;
27+
use SetupClassResourcesTrait;
28+
29+
protected static ?bool $alwaysBootKernel = false;
30+
31+
/**
32+
* @return class-string[]
33+
*/
34+
public static function getResources(): array
35+
{
36+
return [Chicken::class, ChickenCoop::class, Owner::class];
37+
}
38+
39+
protected function setUp(): void
40+
{
41+
$this->recreateSchema([Chicken::class, ChickenCoop::class, Owner::class]);
42+
$this->loadFixtures();
43+
}
44+
45+
#[DataProvider('comparisonFilterProvider')]
46+
public function testComparisonFilter(string $url, int $expectedCount, array $expectedNames): void
47+
{
48+
$response = self::createClient()->request('GET', $url);
49+
$this->assertResponseIsSuccessful();
50+
51+
$responseData = $response->toArray();
52+
$filteredItems = $responseData['member'];
53+
54+
$this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url));
55+
56+
$names = array_map(static fn ($chicken) => $chicken['name'], $filteredItems);
57+
sort($names);
58+
sort($expectedNames);
59+
60+
$this->assertSame($expectedNames, $names, 'The names do not match the expected values.');
61+
}
62+
63+
public static function comparisonFilterProvider(): \Generator
64+
{
65+
// We create 4 chickens with IDs 1-4: Alpha, Bravo, Charlie, Delta
66+
yield 'gt: id > 2 returns chickens 3,4' => [
67+
'/chickens?idComparison[gt]=2',
68+
2,
69+
['Charlie', 'Delta'],
70+
];
71+
72+
yield 'gte: id >= 2 returns chickens 2,3,4' => [
73+
'/chickens?idComparison[gte]=2',
74+
3,
75+
['Bravo', 'Charlie', 'Delta'],
76+
];
77+
78+
yield 'lt: id < 3 returns chickens 1,2' => [
79+
'/chickens?idComparison[lt]=3',
80+
2,
81+
['Alpha', 'Bravo'],
82+
];
83+
84+
yield 'lte: id <= 3 returns chickens 1,2,3' => [
85+
'/chickens?idComparison[lte]=3',
86+
3,
87+
['Alpha', 'Bravo', 'Charlie'],
88+
];
89+
90+
yield 'between: id between 2..3 returns chickens 2,3' => [
91+
'/chickens?idComparison[between]=2..3',
92+
2,
93+
['Bravo', 'Charlie'],
94+
];
95+
96+
yield 'between equal values: id between 2..2 returns chicken 2 (equality)' => [
97+
'/chickens?idComparison[between]=2..2',
98+
1,
99+
['Bravo'],
100+
];
101+
102+
yield 'combined gt and lt: id > 1 AND id < 4 returns chickens 2,3' => [
103+
'/chickens?idComparison[gt]=1&idComparison[lt]=4',
104+
2,
105+
['Bravo', 'Charlie'],
106+
];
107+
108+
yield 'gt with no results: id > 100 returns nothing' => [
109+
'/chickens?idComparison[gt]=100',
110+
0,
111+
[],
112+
];
113+
114+
yield 'gte with large range returns all' => [
115+
'/chickens?idComparison[gte]=1&itemsPerPage=10',
116+
4,
117+
['Alpha', 'Bravo', 'Charlie', 'Delta'],
118+
];
119+
}
120+
121+
private function loadFixtures(): void
122+
{
123+
$manager = $this->getManager();
124+
125+
$owner = new Owner();
126+
$owner->setName('TestOwner');
127+
$manager->persist($owner);
128+
129+
$coop = new ChickenCoop();
130+
$manager->persist($coop);
131+
132+
foreach (['Alpha', 'Bravo', 'Charlie', 'Delta'] as $name) {
133+
$chicken = new Chicken();
134+
$chicken->setName($name);
135+
$chicken->setEan('000000000000');
136+
$chicken->setChickenCoop($coop);
137+
$chicken->setOwner($owner);
138+
$coop->addChicken($chicken);
139+
$manager->persist($chicken);
140+
}
141+
142+
$manager->flush();
143+
}
144+
}

0 commit comments

Comments
 (0)