Skip to content

Commit d37a753

Browse files
authored
feat(doctrine): standalone Date/Exists filters, ComparisonFilter [between], deprecate RangeFilter (#8351)
1 parent 63d345d commit d37a753

15 files changed

Lines changed: 493 additions & 26 deletions
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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\Common\Filter;
15+
16+
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
17+
18+
/**
19+
* Holds an optional name converter and (de)normalizes property names through it.
20+
*
21+
* @author Antoine Bluchet <soyuka@gmail.com>
22+
*/
23+
trait NameConverterAwareTrait
24+
{
25+
private ?NameConverterInterface $nameConverter = null;
26+
27+
public function hasNameConverter(): bool
28+
{
29+
return $this->nameConverter instanceof NameConverterInterface;
30+
}
31+
32+
public function getNameConverter(): ?NameConverterInterface
33+
{
34+
return $this->nameConverter;
35+
}
36+
37+
public function setNameConverter(NameConverterInterface $nameConverter): void
38+
{
39+
$this->nameConverter = $nameConverter;
40+
}
41+
42+
protected function denormalizePropertyName(string|int $property): string
43+
{
44+
if (!$this->nameConverter instanceof NameConverterInterface) {
45+
return (string) $property;
46+
}
47+
48+
return implode('.', array_map($this->nameConverter->denormalize(...), explode('.', (string) $property)));
49+
}
50+
51+
protected function normalizePropertyName(string $property): string
52+
{
53+
if (!$this->nameConverter instanceof NameConverterInterface) {
54+
return $property;
55+
}
56+
57+
return implode('.', array_map($this->nameConverter->normalize(...), explode('.', $property)));
58+
}
59+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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\Common\Filter;
15+
16+
/**
17+
* @author Antoine Bluchet <soyuka@gmail.com>
18+
*/
19+
trait PropertyAwareFilterTrait
20+
{
21+
/**
22+
* @var array<string, mixed>|null
23+
*/
24+
private ?array $properties = null;
25+
26+
public function getProperties(): ?array
27+
{
28+
return $this->properties;
29+
}
30+
31+
/**
32+
* @param array<string, mixed> $properties
33+
*/
34+
public function setProperties(array $properties): void
35+
{
36+
$this->properties = $properties;
37+
}
38+
}

src/Doctrine/Odm/Filter/ComparisonFilter.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ final class ComparisonFilter implements FilterInterface, OpenApiParameterFilterI
4545
'ne' => 'notEqual',
4646
];
4747

48+
/**
49+
* Friendly range syntax: `?price[between]=10..100`. MongoDB has no BETWEEN keyword, so a range
50+
* is expressed as the native `gte`/`lte` pair on the field.
51+
*/
52+
public const OPERATOR_BETWEEN = 'between';
53+
4854
public function __construct(private readonly FilterInterface $filter)
4955
{
5056
}
@@ -74,6 +80,12 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera
7480
continue;
7581
}
7682

83+
if (self::OPERATOR_BETWEEN === $operator) {
84+
$this->applyBetween($aggregationBuilder, $resourceClass, $operation, $context, $parameter, $value);
85+
86+
continue;
87+
}
88+
7789
if (isset(self::OPERATORS[$operator])) {
7890
$this->applyOperator($aggregationBuilder, $resourceClass, $operation, $context, $parameter, self::OPERATORS[$operator], $value);
7991
}
@@ -91,6 +103,7 @@ public function getOpenApiParameters(Parameter $parameter): array
91103
new OpenApiParameter(name: "{$key}[lt]", in: $in),
92104
new OpenApiParameter(name: "{$key}[lte]", in: $in),
93105
new OpenApiParameter(name: "{$key}[ne]", in: $in),
106+
new OpenApiParameter(name: "{$key}[between]", in: $in),
94107
];
95108
}
96109

@@ -109,6 +122,7 @@ public function getSchema(Parameter $parameter): array
109122
'lt' => $innerSchema,
110123
'lte' => $innerSchema,
111124
'ne' => $innerSchema,
125+
'between' => ['type' => 'string'],
112126
],
113127
];
114128
}
@@ -131,4 +145,25 @@ private function applyOperator(Builder $aggregationBuilder, string $resourceClas
131145
$context['match'] = $newContext['match'];
132146
}
133147
}
148+
149+
/**
150+
* @param array<string, mixed> $context
151+
*
152+
* @param-out array<string, mixed> $context
153+
*/
154+
private function applyBetween(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation, array &$context, Parameter $parameter, mixed $value): void
155+
{
156+
if (!\is_string($value)) {
157+
return;
158+
}
159+
160+
$bounds = explode('..', $value, 2);
161+
if (2 !== \count($bounds) || !is_numeric($bounds[0]) || !is_numeric($bounds[1])) {
162+
return;
163+
}
164+
165+
// MongoDB range = native gte/lte pair (coerce bounds to numbers)
166+
$this->applyOperator($aggregationBuilder, $resourceClass, $operation, $context, $parameter, 'gte', $bounds[0] + 0);
167+
$this->applyOperator($aggregationBuilder, $resourceClass, $operation, $context, $parameter, 'lte', $bounds[1] + 0);
168+
}
134169
}

src/Doctrine/Odm/Filter/DateFilter.php

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@
1515

1616
use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
1717
use ApiPlatform\Doctrine\Common\Filter\DateFilterTrait;
18+
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
19+
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait;
20+
use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareInterface;
21+
use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareTrait;
22+
use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface;
23+
use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterTrait;
24+
use ApiPlatform\Doctrine\Odm\PropertyHelperTrait as MongoDbOdmPropertyHelperTrait;
1825
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
1926
use ApiPlatform\Metadata\JsonSchemaFilterInterface;
2027
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
@@ -23,6 +30,10 @@
2330
use ApiPlatform\Metadata\QueryParameter;
2431
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
2532
use Doctrine\ODM\MongoDB\Aggregation\Builder;
33+
use Doctrine\Persistence\ManagerRegistry;
34+
use Psr\Log\LoggerInterface;
35+
use Psr\Log\NullLogger;
36+
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
2637

2738
/**
2839
* The date filter allows to filter a collection by date intervals.
@@ -120,20 +131,59 @@
120131
* @author Kévin Dunglas <dunglas@gmail.com>
121132
* @author Théo FIDRY <theo.fidry@gmail.com>
122133
* @author Alan Poulain <contact@alanpoulain.eu>
123-
*
124-
* @deprecated since API Platform 4.4: extending {@see AbstractFilter} is deprecated. In 5.0 this filter is rewritten as a standalone overlay over {@see ComparisonFilter} (translating the `[before]`/`[strictly_before]`/`[after]`/`[strictly_after]` syntax) — same class name, same URL syntax, drop-in. Declare it through a QueryParameter to migrate.
125134
*/
126-
final class DateFilter extends AbstractFilter implements DateFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface
135+
final class DateFilter implements DateFilterInterface, FilterInterface, JsonSchemaFilterInterface, ManagerRegistryAwareInterface, NameConverterAwareInterface, OpenApiParameterFilterInterface, PropertyAwareFilterInterface
127136
{
128137
use DateFilterTrait;
138+
use ManagerRegistryAwareTrait;
139+
use MongoDbOdmPropertyHelperTrait;
140+
use NameConverterAwareTrait;
141+
use PropertyAwareFilterTrait;
129142

130143
public const DOCTRINE_DATE_TYPES = [
131144
'date' => true,
132145
'date_immutable' => true,
133146
];
134147

148+
private LoggerInterface $logger;
149+
150+
/**
151+
* @param array<string, mixed>|null $properties
152+
*/
153+
public function __construct(?ManagerRegistry $managerRegistry = null, ?LoggerInterface $logger = null, ?array $properties = null, ?NameConverterInterface $nameConverter = null)
154+
{
155+
$this->managerRegistry = $managerRegistry;
156+
$this->logger = $logger ?? new NullLogger();
157+
$this->properties = $properties;
158+
$this->nameConverter = $nameConverter;
159+
}
160+
161+
public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
162+
{
163+
foreach ($context['filters'] ?? [] as $property => $value) {
164+
$this->filterProperty($this->denormalizePropertyName($property), $value, $aggregationBuilder, $resourceClass, $operation, $context);
165+
}
166+
}
167+
168+
protected function getLogger(): LoggerInterface
169+
{
170+
return $this->logger;
171+
}
172+
173+
protected function isPropertyEnabled(string $property, string $resourceClass): bool
174+
{
175+
if (null === $this->properties) {
176+
// to ensure sanity, nested properties must still be explicitly enabled
177+
return !$this->isPropertyNested($property, $resourceClass);
178+
}
179+
180+
return \array_key_exists($property, $this->properties);
181+
}
182+
135183
/**
136-
* {@inheritdoc}
184+
* @param array<string, mixed> $context
185+
*
186+
* @param-out array<string, mixed> $context
137187
*/
138188
protected function filterProperty(string $property, mixed $value, Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
139189
{

src/Doctrine/Odm/Filter/ExistsFilter.php

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,14 @@
1515

1616
use ApiPlatform\Doctrine\Common\Filter\ExistsFilterInterface;
1717
use ApiPlatform\Doctrine\Common\Filter\ExistsFilterTrait;
18+
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
19+
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait;
20+
use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareInterface;
21+
use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareTrait;
22+
use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface;
23+
use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterTrait;
1824
use ApiPlatform\Doctrine\Common\Filter\PropertyPlaceholderOpenApiParameterTrait;
25+
use ApiPlatform\Doctrine\Odm\PropertyHelperTrait as MongoDbOdmPropertyHelperTrait;
1926
use ApiPlatform\Metadata\JsonSchemaFilterInterface;
2027
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
2128
use ApiPlatform\Metadata\Operation;
@@ -24,6 +31,7 @@
2431
use Doctrine\ODM\MongoDB\Mapping\ClassMetadata;
2532
use Doctrine\Persistence\ManagerRegistry;
2633
use Psr\Log\LoggerInterface;
34+
use Psr\Log\NullLogger;
2735
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
2836

2937
/**
@@ -110,19 +118,43 @@
110118
*
111119
* @author Teoh Han Hui <teohhanhui@gmail.com>
112120
* @author Alan Poulain <contact@alanpoulain.eu>
113-
*
114-
* @deprecated since API Platform 4.4: extending {@see AbstractFilter} is deprecated. In 5.0 this filter is rewritten as a standalone filter (reading its value from the QueryParameter instead of the legacy `context['filters']` lookup) — same class name, same URL syntax, drop-in. Declare it through a QueryParameter to migrate.
115121
*/
116-
final class ExistsFilter extends AbstractFilter implements ExistsFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface
122+
final class ExistsFilter implements ExistsFilterInterface, FilterInterface, JsonSchemaFilterInterface, ManagerRegistryAwareInterface, NameConverterAwareInterface, OpenApiParameterFilterInterface, PropertyAwareFilterInterface
117123
{
118124
use ExistsFilterTrait;
125+
use ManagerRegistryAwareTrait;
126+
use MongoDbOdmPropertyHelperTrait;
127+
use NameConverterAwareTrait;
128+
use PropertyAwareFilterTrait;
119129
use PropertyPlaceholderOpenApiParameterTrait;
120130

131+
private LoggerInterface $logger;
132+
133+
/**
134+
* @param array<string, mixed>|null $properties
135+
*/
121136
public function __construct(?ManagerRegistry $managerRegistry = null, ?LoggerInterface $logger = null, ?array $properties = null, string $existsParameterName = self::QUERY_PARAMETER_KEY, ?NameConverterInterface $nameConverter = null)
122137
{
123-
parent::__construct($managerRegistry, $logger, $properties, $nameConverter);
124-
138+
$this->managerRegistry = $managerRegistry;
139+
$this->logger = $logger ?? new NullLogger();
125140
$this->existsParameterName = $existsParameterName;
141+
$this->properties = $properties;
142+
$this->nameConverter = $nameConverter;
143+
}
144+
145+
protected function getLogger(): LoggerInterface
146+
{
147+
return $this->logger;
148+
}
149+
150+
protected function isPropertyEnabled(string $property, string $resourceClass): bool
151+
{
152+
if (null === $this->properties) {
153+
// to ensure sanity, nested properties must still be explicitly enabled
154+
return !$this->isPropertyNested($property, $resourceClass);
155+
}
156+
157+
return \array_key_exists($property, $this->properties);
126158
}
127159

128160
/**
@@ -143,7 +175,9 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera
143175
}
144176

145177
/**
146-
* {@inheritdoc}
178+
* @param array<string, mixed> $context
179+
*
180+
* @param-out array<string, mixed> $context
147181
*/
148182
protected function filterProperty(string $property, mixed $value, Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
149183
{

src/Doctrine/Odm/Filter/RangeFilter.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@
108108
* @author Lee Siong Chan <ahlee2326@me.com>
109109
* @author Alan Poulain <contact@alanpoulain.eu>
110110
*
111-
* @deprecated since API Platform 4.4: extending {@see AbstractFilter} is deprecated. In 5.0 this filter is rewritten as a standalone overlay over {@see ComparisonFilter} (translating `[between]=X..Y` to `[gte]=X` + `[lte]=Y`, passing through `[gt]`/`[gte]`/`[lt]`/`[lte]`) — same class name, same URL syntax, drop-in. Declare it through a QueryParameter to migrate.
111+
* @deprecated since API Platform 4.4: use {@see ComparisonFilter} instead, which now covers the full range syntax (`[gt]`/`[gte]`/`[lt]`/`[lte]` and `[between]=X..Y`). This filter is removed in 6.0; the upgrade codemod rewrites it to a QueryParameter declared with `ComparisonFilter`.
112112
*/
113113
final class RangeFilter extends AbstractFilter implements RangeFilterInterface, OpenApiParameterFilterInterface
114114
{

0 commit comments

Comments
 (0)