Skip to content

Commit 70600dc

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 23840f9 commit 70600dc

5 files changed

Lines changed: 399 additions & 8 deletions

File tree

src/Doctrine/Orm/Filter/AbstractUuidFilter.php

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,14 @@ private function filterProperty(Parameter $parameter, QueryBuilder $queryBuilder
9696

9797
$metadata = $this->getClassMetadata($targetResourceClass);
9898

99+
$operator = $context['operator'] ?? '=';
100+
if (!\in_array($operator, ComparisonFilter::ALLOWED_DQL_OPERATORS, true)) {
101+
throw new InvalidArgumentException(\sprintf('Unsupported operator "%s".', $operator));
102+
}
103+
99104
if ($metadata->hasField($field)) {
100105
$value = $this->convertValuesToTheDatabaseRepresentation($queryBuilder, $this->getDoctrineFieldType($field, $targetResourceClass), $value);
101-
$this->addWhere($queryBuilder, $queryNameGenerator, $alias, $field, $value);
106+
$this->addWhere($queryBuilder, $queryNameGenerator, $alias, $field, $value, $operator, $context);
102107

103108
return;
104109
}
@@ -129,7 +134,7 @@ private function filterProperty(Parameter $parameter, QueryBuilder $queryBuilder
129134
}
130135

131136
$value = $this->convertValuesToTheDatabaseRepresentation($queryBuilder, $doctrineTypeField, $value);
132-
$this->addWhere($queryBuilder, $queryNameGenerator, $associationAlias, $associationField, $value);
137+
$this->addWhere($queryBuilder, $queryNameGenerator, $associationAlias, $associationField, $value, $operator, $context);
133138
}
134139

135140
/**
@@ -162,21 +167,28 @@ private function convertValuesToTheDatabaseRepresentation(QueryBuilder $queryBui
162167
/**
163168
* Adds where clause.
164169
*/
165-
private function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, mixed $value): void
170+
private function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, mixed $value, string $operator = '=', array $context = []): void
166171
{
167172
$valueParameter = ':'.$queryNameGenerator->generateParameterName($field);
168173
$aliasedField = \sprintf('%s.%s', $alias, $field);
174+
$whereClause = $context['whereClause'] ?? 'andWhere';
169175

170176
if (!\is_array($value)) {
171-
$queryBuilder
172-
->andWhere($queryBuilder->expr()->eq($aliasedField, $valueParameter))
173-
->setParameter($valueParameter, $value, $this->getDoctrineParameterType());
177+
if ('=' === $operator) {
178+
$queryBuilder
179+
->{$whereClause}($queryBuilder->expr()->eq($aliasedField, $valueParameter))
180+
->setParameter($valueParameter, $value, $this->getDoctrineParameterType());
181+
} else {
182+
$queryBuilder
183+
->{$whereClause}(\sprintf('%s %s %s', $aliasedField, $operator, $valueParameter))
184+
->setParameter($valueParameter, $value, $this->getDoctrineParameterType());
185+
}
174186

175187
return;
176188
}
177189

178190
$queryBuilder
179-
->andWhere($queryBuilder->expr()->in($aliasedField, $valueParameter))
191+
->{$whereClause}($queryBuilder->expr()->in($aliasedField, $valueParameter))
180192
->setParameter($valueParameter, $value, $this->getDoctrineArrayParameterType());
181193
}
182194

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
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\JsonSchemaFilterInterface;
24+
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
25+
use ApiPlatform\Metadata\Operation;
26+
use ApiPlatform\Metadata\Parameter;
27+
use ApiPlatform\Metadata\QueryParameter;
28+
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
29+
use Doctrine\ORM\QueryBuilder;
30+
31+
/**
32+
* Decorates an equality filter (ExactFilter, UuidFilter) to add comparison operators (gt, gte, lt, lte, between).
33+
*
34+
* @experimental
35+
*/
36+
final class ComparisonFilter implements FilterInterface, OpenApiParameterFilterInterface, JsonSchemaFilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface
37+
{
38+
use BackwardCompatibleFilterDescriptionTrait;
39+
use LoggerAwareTrait;
40+
use ManagerRegistryAwareTrait;
41+
use OpenApiFilterTrait;
42+
43+
private const OPERATORS = [
44+
'gt' => '>',
45+
'gte' => '>=',
46+
'lt' => '<',
47+
'lte' => '<=',
48+
];
49+
50+
public const ALLOWED_DQL_OPERATORS = ['=', '>', '>=', '<', '<=', '!=', '<>'];
51+
52+
public function __construct(private readonly FilterInterface $filter)
53+
{
54+
}
55+
56+
public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
57+
{
58+
if ($this->filter instanceof ManagerRegistryAwareInterface) {
59+
$this->filter->setManagerRegistry($this->getManagerRegistry());
60+
}
61+
62+
if ($this->filter instanceof LoggerAwareInterface) {
63+
$this->filter->setLogger($this->getLogger());
64+
}
65+
66+
$parameter = $context['parameter'];
67+
$values = $parameter->getValue();
68+
69+
if (!\is_array($values)) {
70+
return;
71+
}
72+
73+
foreach ($values as $operator => $value) {
74+
if ('' === $value || null === $value) {
75+
continue;
76+
}
77+
78+
if (isset(self::OPERATORS[$operator])) {
79+
if (!\is_string($value) && !is_numeric($value)) {
80+
continue;
81+
}
82+
$subParameter = (clone $parameter)->setValue($value);
83+
$this->filter->apply(
84+
$queryBuilder,
85+
$queryNameGenerator,
86+
$resourceClass,
87+
$operation,
88+
['operator' => self::OPERATORS[$operator], 'parameter' => $subParameter] + $context
89+
);
90+
continue;
91+
}
92+
93+
if ('between' === $operator) {
94+
if (!\is_string($value)) {
95+
continue;
96+
}
97+
$range = explode('..', $value, 2);
98+
if (2 !== \count($range)) {
99+
continue;
100+
}
101+
102+
if ($range[0] === $range[1]) {
103+
$subParameter = (clone $parameter)->setValue($range[0]);
104+
$this->filter->apply(
105+
$queryBuilder,
106+
$queryNameGenerator,
107+
$resourceClass,
108+
$operation,
109+
['parameter' => $subParameter] + $context
110+
);
111+
} else {
112+
$subParameter = (clone $parameter)->setValue($range[0]);
113+
$this->filter->apply(
114+
$queryBuilder,
115+
$queryNameGenerator,
116+
$resourceClass,
117+
$operation,
118+
['operator' => '>=', 'parameter' => $subParameter] + $context
119+
);
120+
121+
$subParameter = (clone $parameter)->setValue($range[1]);
122+
$this->filter->apply(
123+
$queryBuilder,
124+
$queryNameGenerator,
125+
$resourceClass,
126+
$operation,
127+
['operator' => '<=', 'parameter' => $subParameter] + $context
128+
);
129+
}
130+
}
131+
}
132+
}
133+
134+
public function getOpenApiParameters(Parameter $parameter): array
135+
{
136+
$in = $parameter instanceof QueryParameter ? 'query' : 'header';
137+
$key = $parameter->getKey();
138+
$schema = $this->getInnerSchema($parameter);
139+
140+
return [
141+
new OpenApiParameter(name: "{$key}[gt]", in: $in, schema: $schema),
142+
new OpenApiParameter(name: "{$key}[gte]", in: $in, schema: $schema),
143+
new OpenApiParameter(name: "{$key}[lt]", in: $in, schema: $schema),
144+
new OpenApiParameter(name: "{$key}[lte]", in: $in, schema: $schema),
145+
new OpenApiParameter(name: "{$key}[between]", in: $in, schema: ['type' => 'string', 'pattern' => '\\d+\\.\\.\\d+']),
146+
];
147+
}
148+
149+
public function getSchema(Parameter $parameter): array
150+
{
151+
$innerSchema = $this->getInnerSchema($parameter);
152+
153+
return [
154+
'type' => 'object',
155+
'properties' => [
156+
'gt' => $innerSchema,
157+
'gte' => $innerSchema,
158+
'lt' => $innerSchema,
159+
'lte' => $innerSchema,
160+
'between' => ['type' => 'string', 'pattern' => '.+\\.\\..+'],
161+
],
162+
];
163+
}
164+
165+
private function getInnerSchema(Parameter $parameter): array
166+
{
167+
if ($this->filter instanceof JsonSchemaFilterInterface) {
168+
return $this->filter->getSchema($parameter);
169+
}
170+
171+
return ['type' => 'string'];
172+
}
173+
}

src/Doctrine/Orm/Filter/ExactFilter.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,12 @@ 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'] ?? '=';
54+
if (!\in_array($operator, ComparisonFilter::ALLOWED_DQL_OPERATORS, true)) {
55+
throw new InvalidArgumentException(\sprintf('Unsupported operator "%s".', $operator));
56+
}
5357
$queryBuilder
54-
->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s = :%s', $alias, $property, $parameterName));
58+
->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s %s :%s', $alias, $property, $operator, $parameterName));
5559
}
5660

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

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(),

0 commit comments

Comments
 (0)