Skip to content

Commit 4b9b6ac

Browse files
soyukaclaude
andcommitted
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 operators. Follows the same pattern as OrFilter by injecting $context['operator']. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 23840f9 commit 4b9b6ac

9 files changed

Lines changed: 401 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: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
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).
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+
$this->applyOperator($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context, $parameter, self::OPERATORS[$operator], $value);
80+
}
81+
}
82+
}
83+
84+
public function getOpenApiParameters(Parameter $parameter): array
85+
{
86+
$in = $parameter instanceof QueryParameter ? 'query' : 'header';
87+
$key = $parameter->getKey();
88+
$schema = $this->getInnerSchema($parameter);
89+
90+
return [
91+
new OpenApiParameter(name: "{$key}[gt]", in: $in, schema: $schema),
92+
new OpenApiParameter(name: "{$key}[gte]", in: $in, schema: $schema),
93+
new OpenApiParameter(name: "{$key}[lt]", in: $in, schema: $schema),
94+
new OpenApiParameter(name: "{$key}[lte]", in: $in, schema: $schema),
95+
];
96+
}
97+
98+
public function getSchema(Parameter $parameter): array
99+
{
100+
$innerSchema = $this->getInnerSchema($parameter);
101+
102+
return [
103+
'type' => 'object',
104+
'properties' => [
105+
'gt' => $innerSchema,
106+
'gte' => $innerSchema,
107+
'lt' => $innerSchema,
108+
'lte' => $innerSchema,
109+
],
110+
];
111+
}
112+
113+
private function applyOperator(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation, array $context, Parameter $parameter, string $operator, mixed $value): void
114+
{
115+
if (!\is_string($value) && !is_numeric($value) && !$value instanceof \DateTimeInterface) {
116+
return;
117+
}
118+
119+
$subParameter = (clone $parameter)->setValue($value);
120+
$this->filter->apply(
121+
$queryBuilder,
122+
$queryNameGenerator,
123+
$resourceClass,
124+
$operation,
125+
['operator' => $operator, 'parameter' => $subParameter] + $context
126+
);
127+
}
128+
129+
private function getInnerSchema(Parameter $parameter): array
130+
{
131+
if ($this->filter instanceof JsonSchemaFilterInterface) {
132+
return $this->filter->getSchema($parameter);
133+
}
134+
135+
return ['type' => 'string'];
136+
}
137+
}

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);

src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,14 @@ private function getDefaultParameters(Operation $operation, string $resourceClas
311311
}
312312
}
313313

314+
if ($parameter->getCastToNativeType() && null === $parameter->getCastFn()) {
315+
$propertyKey = $parameter->getProperty() ?? $key;
316+
$propNativeType = $properties[$propertyKey]?->getNativeType() ?? null;
317+
if ($propNativeType && $propNativeType->isIdentifiedBy(\DateTimeInterface::class)) {
318+
$parameter = $parameter->withCastFn([ValueCaster::class, 'toDateTime']);
319+
}
320+
}
321+
314322
$priority = $parameter->getPriority() ?? $internalPriority--;
315323
$parameters->add($key, $parameter->withPriority($priority));
316324
}

src/State/Parameter/ValueCaster.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,21 @@ public static function toFloat(mixed $v): mixed
5656

5757
return false === $value ? $v : $value;
5858
}
59+
60+
public static function toDateTime(mixed $v): mixed
61+
{
62+
if ($v instanceof \DateTimeInterface) {
63+
return $v;
64+
}
65+
66+
if (!\is_string($v)) {
67+
return $v;
68+
}
69+
70+
try {
71+
return new \DateTimeImmutable($v);
72+
} catch (\Exception) {
73+
return $v;
74+
}
75+
}
5976
}

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

tests/Fixtures/TestBundle/Entity/FilteredDateParameter.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity;
1515

1616
use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
17+
use ApiPlatform\Doctrine\Orm\Filter\ComparisonFilter;
1718
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
19+
use ApiPlatform\Doctrine\Orm\Filter\ExactFilter;
1820
use ApiPlatform\Metadata\ApiResource;
1921
use ApiPlatform\Metadata\GetCollection;
2022
use ApiPlatform\Metadata\QueryParameter;
@@ -45,6 +47,11 @@
4547
property: 'createdAt',
4648
openApi: new Parameter('date_old_way', 'query', allowEmptyValue: true)
4749
),
50+
'createdAtComparison' => new QueryParameter(
51+
filter: new ComparisonFilter(new ExactFilter()),
52+
property: 'createdAt',
53+
castToNativeType: true,
54+
),
4855
],
4956
)]
5057
#[ORM\Entity]

0 commit comments

Comments
 (0)