Skip to content

Commit 5a876cc

Browse files
soyukaclaude
andauthored
feat(doctrine): ComparisonFilter decorator for range filtering (#7760)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 95e983a commit 5a876cc

17 files changed

Lines changed: 601 additions & 50 deletions

src/Doctrine/Odm/Extension/ParameterExtension.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,18 @@ private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass
6969

7070
$this->configureFilter($filter, $parameter);
7171

72+
$previousFilters = $context['filters'] ?? null;
7273
$context['filters'] = $values;
7374
$context['parameter'] = $parameter;
7475

7576
$filter->apply($aggregationBuilder, $resourceClass, $operation, $context);
7677

77-
unset($context['filters'], $context['parameter']);
78+
unset($context['parameter']);
79+
if (null !== $previousFilters) {
80+
$context['filters'] = $previousFilters;
81+
} else {
82+
unset($context['filters']);
83+
}
7884
}
7985

8086
if (isset($context['match'])) {
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
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\Odm\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\Metadata\BackwardCompatibleFilterDescriptionTrait;
22+
use ApiPlatform\Metadata\JsonSchemaFilterInterface;
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\ODM\MongoDB\Aggregation\Builder;
29+
30+
/**
31+
* Decorates an equality filter (ExactFilter) to add comparison operators (gt, gte, lt, lte).
32+
*
33+
* @experimental
34+
*/
35+
final class ComparisonFilter implements FilterInterface, OpenApiParameterFilterInterface, JsonSchemaFilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface
36+
{
37+
use BackwardCompatibleFilterDescriptionTrait;
38+
use LoggerAwareTrait;
39+
use ManagerRegistryAwareTrait;
40+
use OpenApiFilterTrait;
41+
42+
private const OPERATORS = [
43+
'gt' => 'gt',
44+
'gte' => 'gte',
45+
'lt' => 'lt',
46+
'lte' => 'lte',
47+
];
48+
49+
public function __construct(private readonly FilterInterface $filter)
50+
{
51+
}
52+
53+
/**
54+
* @param-out array<string, mixed> $context
55+
*/
56+
public function apply(Builder $aggregationBuilder, 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($aggregationBuilder, $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+
89+
return [
90+
new OpenApiParameter(name: "{$key}[gt]", in: $in),
91+
new OpenApiParameter(name: "{$key}[gte]", in: $in),
92+
new OpenApiParameter(name: "{$key}[lt]", in: $in),
93+
new OpenApiParameter(name: "{$key}[lte]", in: $in),
94+
];
95+
}
96+
97+
public function getSchema(Parameter $parameter): array
98+
{
99+
$innerSchema = ['type' => 'string'];
100+
if ($this->filter instanceof JsonSchemaFilterInterface) {
101+
$innerSchema = $this->filter->getSchema($parameter);
102+
}
103+
104+
return [
105+
'type' => 'object',
106+
'properties' => [
107+
'gt' => $innerSchema,
108+
'gte' => $innerSchema,
109+
'lt' => $innerSchema,
110+
'lte' => $innerSchema,
111+
],
112+
];
113+
}
114+
115+
/**
116+
* @param array<string, mixed> $context
117+
*
118+
* @param-out array<string, mixed> $context
119+
*/
120+
private function applyOperator(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation, array &$context, Parameter $parameter, string $comparisonMethod, mixed $value): void
121+
{
122+
if (!\is_string($value) && !is_numeric($value) && !$value instanceof \DateTimeInterface) {
123+
return;
124+
}
125+
126+
$subParameter = (clone $parameter)->setValue($value);
127+
$newContext = ['comparisonMethod' => $comparisonMethod, 'parameter' => $subParameter] + $context;
128+
$this->filter->apply($aggregationBuilder, $resourceClass, $operation, $newContext);
129+
if (isset($newContext['match'])) {
130+
$context['match'] = $newContext['match'];
131+
}
132+
}
133+
}

src/Doctrine/Odm/Filter/ExactFilter.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,9 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera
6161
$classMetadata = $documentManager->getClassMetadata($resourceClass);
6262

6363
if (!$classMetadata->hasReference($property)) {
64+
$comparisonMethod = $context['comparisonMethod'] ?? (is_iterable($value) ? 'in' : 'equals');
6465
$match
65-
->{$operator}($aggregationBuilder->matchExpr()->field($property)->{is_iterable($value) ? 'in' : 'equals'}($value));
66+
->{$operator}($aggregationBuilder->matchExpr()->field($property)->{$comparisonMethod}($value));
6667

6768
return;
6869
}

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

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] ?? null)?->getNativeType();
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
}

0 commit comments

Comments
 (0)